diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..46fa5e8dd0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,1118 @@ +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = false +max_line_length = 120 +tab_width = 4 +ij_continuation_indent_size = 8 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = true +ij_smart_tabs = false +ij_visual_guides = none +ij_wrap_on_typing = false + +[*.css] +ij_css_align_closing_brace_with_properties = false +ij_css_blank_lines_around_nested_selector = 1 +ij_css_blank_lines_between_blocks = 1 +ij_css_brace_placement = end_of_line +ij_css_enforce_quotes_on_format = false +ij_css_hex_color_long_format = false +ij_css_hex_color_lower_case = false +ij_css_hex_color_short_format = false +ij_css_hex_color_upper_case = false +ij_css_keep_blank_lines_in_code = 2 +ij_css_keep_indents_on_empty_lines = false +ij_css_keep_single_line_blocks = false +ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow +ij_css_space_after_colon = true +ij_css_space_before_opening_brace = true +ij_css_use_double_quotes = true +ij_css_value_alignment = do_not_align + +[*.feature] +indent_size = 2 +ij_gherkin_keep_indents_on_empty_lines = false + +[*.gsp] +ij_gsp_keep_indents_on_empty_lines = false + +[*.haml] +indent_size = 2 +ij_haml_keep_indents_on_empty_lines = false + +[*.java] +ij_java_align_consecutive_assignments = false +ij_java_align_consecutive_variable_declarations = false +ij_java_align_group_field_declarations = false +ij_java_align_multiline_annotation_parameters = false +ij_java_align_multiline_array_initializer_expression = false +ij_java_align_multiline_assignment = false +ij_java_align_multiline_binary_operation = false +ij_java_align_multiline_chained_methods = false +ij_java_align_multiline_extends_list = false +ij_java_align_multiline_for = true +ij_java_align_multiline_method_parentheses = false +ij_java_align_multiline_parameters = true +ij_java_align_multiline_parameters_in_calls = false +ij_java_align_multiline_parenthesized_expression = false +ij_java_align_multiline_records = true +ij_java_align_multiline_resources = true +ij_java_align_multiline_ternary_operation = false +ij_java_align_multiline_text_blocks = false +ij_java_align_multiline_throws_list = false +ij_java_align_subsequent_simple_methods = false +ij_java_align_throws_keyword = false +ij_java_annotation_parameter_wrap = off +ij_java_array_initializer_new_line_after_left_brace = false +ij_java_array_initializer_right_brace_on_new_line = false +ij_java_array_initializer_wrap = off +ij_java_assert_statement_colon_on_next_line = false +ij_java_assert_statement_wrap = off +ij_java_assignment_wrap = off +ij_java_binary_operation_sign_on_next_line = false +ij_java_binary_operation_wrap = off +ij_java_blank_lines_after_anonymous_class_header = 0 +ij_java_blank_lines_after_class_header = 0 +ij_java_blank_lines_after_imports = 1 +ij_java_blank_lines_after_package = 1 +ij_java_blank_lines_around_class = 1 +ij_java_blank_lines_around_field = 0 +ij_java_blank_lines_around_field_in_interface = 0 +ij_java_blank_lines_around_initializer = 1 +ij_java_blank_lines_around_method = 1 +ij_java_blank_lines_around_method_in_interface = 1 +ij_java_blank_lines_before_class_end = 0 +ij_java_blank_lines_before_imports = 1 +ij_java_blank_lines_before_method_body = 0 +ij_java_blank_lines_before_package = 0 +ij_java_block_brace_style = end_of_line +ij_java_block_comment_at_first_column = true +ij_java_builder_methods = none +ij_java_call_parameters_new_line_after_left_paren = false +ij_java_call_parameters_right_paren_on_new_line = false +ij_java_call_parameters_wrap = off +ij_java_case_statement_on_separate_line = true +ij_java_catch_on_new_line = false +ij_java_class_annotation_wrap = split_into_lines +ij_java_class_brace_style = end_of_line +ij_java_class_count_to_use_import_on_demand = 5 +ij_java_class_names_in_javadoc = 1 +ij_java_do_not_indent_top_level_class_members = false +ij_java_do_not_wrap_after_single_annotation = false +ij_java_do_while_brace_force = never +ij_java_doc_add_blank_line_after_description = true +ij_java_doc_add_blank_line_after_param_comments = false +ij_java_doc_add_blank_line_after_return = false +ij_java_doc_add_p_tag_on_empty_lines = true +ij_java_doc_align_exception_comments = true +ij_java_doc_align_param_comments = true +ij_java_doc_do_not_wrap_if_one_line = false +ij_java_doc_enable_formatting = true +ij_java_doc_enable_leading_asterisks = true +ij_java_doc_indent_on_continuation = false +ij_java_doc_keep_empty_lines = true +ij_java_doc_keep_empty_parameter_tag = true +ij_java_doc_keep_empty_return_tag = true +ij_java_doc_keep_empty_throws_tag = true +ij_java_doc_keep_invalid_tags = true +ij_java_doc_param_description_on_new_line = false +ij_java_doc_preserve_line_breaks = false +ij_java_doc_use_throws_not_exception_tag = true +ij_java_else_on_new_line = false +ij_java_entity_dd_suffix = EJB +ij_java_entity_eb_suffix = Bean +ij_java_entity_hi_suffix = Home +ij_java_entity_lhi_prefix = Local +ij_java_entity_lhi_suffix = Home +ij_java_entity_li_prefix = Local +ij_java_entity_pk_class = java.lang.String +ij_java_entity_vo_suffix = VO +ij_java_enum_constants_wrap = off +ij_java_extends_keyword_wrap = off +ij_java_extends_list_wrap = off +ij_java_field_annotation_wrap = split_into_lines +ij_java_finally_on_new_line = false +ij_java_for_brace_force = never +ij_java_for_statement_new_line_after_left_paren = false +ij_java_for_statement_right_paren_on_new_line = false +ij_java_for_statement_wrap = off +ij_java_generate_final_locals = false +ij_java_generate_final_parameters = false +ij_java_if_brace_force = never +ij_java_imports_layout = *,|,javax.**,java.**,|,$* +ij_java_indent_case_from_switch = true +ij_java_insert_inner_class_imports = false +ij_java_insert_override_annotation = true +ij_java_keep_blank_lines_before_right_brace = 2 +ij_java_keep_blank_lines_between_package_declaration_and_header = 2 +ij_java_keep_blank_lines_in_code = 2 +ij_java_keep_blank_lines_in_declarations = 2 +ij_java_keep_builder_methods_indents = false +ij_java_keep_control_statement_in_one_line = true +ij_java_keep_first_column_comment = true +ij_java_keep_indents_on_empty_lines = false +ij_java_keep_line_breaks = true +ij_java_keep_multiple_expressions_in_one_line = false +ij_java_keep_simple_blocks_in_one_line = false +ij_java_keep_simple_classes_in_one_line = false +ij_java_keep_simple_lambdas_in_one_line = false +ij_java_keep_simple_methods_in_one_line = false +ij_java_label_indent_absolute = false +ij_java_label_indent_size = 0 +ij_java_lambda_brace_style = end_of_line +ij_java_layout_static_imports_separately = true +ij_java_line_comment_add_space = false +ij_java_line_comment_at_first_column = true +ij_java_message_dd_suffix = EJB +ij_java_message_eb_suffix = Bean +ij_java_method_annotation_wrap = split_into_lines +ij_java_method_brace_style = end_of_line +ij_java_method_call_chain_wrap = off +ij_java_method_parameters_new_line_after_left_paren = false +ij_java_method_parameters_right_paren_on_new_line = false +ij_java_method_parameters_wrap = off +ij_java_modifier_list_wrap = false +ij_java_names_count_to_use_import_on_demand = 3 +ij_java_new_line_after_lparen_in_record_header = false +ij_java_packages_to_use_import_on_demand = java.awt.*,javax.swing.* +ij_java_parameter_annotation_wrap = off +ij_java_parentheses_expression_new_line_after_left_paren = false +ij_java_parentheses_expression_right_paren_on_new_line = false +ij_java_place_assignment_sign_on_next_line = false +ij_java_prefer_longer_names = true +ij_java_prefer_parameters_wrap = false +ij_java_record_components_wrap = normal +ij_java_repeat_synchronized = true +ij_java_replace_instanceof_and_cast = false +ij_java_replace_null_check = true +ij_java_replace_sum_lambda_with_method_ref = true +ij_java_resource_list_new_line_after_left_paren = false +ij_java_resource_list_right_paren_on_new_line = false +ij_java_resource_list_wrap = off +ij_java_rparen_on_new_line_in_record_header = false +ij_java_session_dd_suffix = EJB +ij_java_session_eb_suffix = Bean +ij_java_session_hi_suffix = Home +ij_java_session_lhi_prefix = Local +ij_java_session_lhi_suffix = Home +ij_java_session_li_prefix = Local +ij_java_session_si_suffix = Service +ij_java_space_after_closing_angle_bracket_in_type_argument = false +ij_java_space_after_colon = true +ij_java_space_after_comma = true +ij_java_space_after_comma_in_type_arguments = true +ij_java_space_after_for_semicolon = true +ij_java_space_after_quest = true +ij_java_space_after_type_cast = true +ij_java_space_before_annotation_array_initializer_left_brace = false +ij_java_space_before_annotation_parameter_list = false +ij_java_space_before_array_initializer_left_brace = false +ij_java_space_before_catch_keyword = true +ij_java_space_before_catch_left_brace = true +ij_java_space_before_catch_parentheses = true +ij_java_space_before_class_left_brace = true +ij_java_space_before_colon = true +ij_java_space_before_colon_in_foreach = true +ij_java_space_before_comma = false +ij_java_space_before_do_left_brace = true +ij_java_space_before_else_keyword = true +ij_java_space_before_else_left_brace = true +ij_java_space_before_finally_keyword = true +ij_java_space_before_finally_left_brace = true +ij_java_space_before_for_left_brace = true +ij_java_space_before_for_parentheses = true +ij_java_space_before_for_semicolon = false +ij_java_space_before_if_left_brace = true +ij_java_space_before_if_parentheses = true +ij_java_space_before_method_call_parentheses = false +ij_java_space_before_method_left_brace = true +ij_java_space_before_method_parentheses = false +ij_java_space_before_opening_angle_bracket_in_type_parameter = false +ij_java_space_before_quest = true +ij_java_space_before_switch_left_brace = true +ij_java_space_before_switch_parentheses = true +ij_java_space_before_synchronized_left_brace = true +ij_java_space_before_synchronized_parentheses = true +ij_java_space_before_try_left_brace = true +ij_java_space_before_try_parentheses = true +ij_java_space_before_type_parameter_list = false +ij_java_space_before_while_keyword = true +ij_java_space_before_while_left_brace = true +ij_java_space_before_while_parentheses = true +ij_java_space_inside_one_line_enum_braces = false +ij_java_space_within_empty_array_initializer_braces = false +ij_java_space_within_empty_method_call_parentheses = false +ij_java_space_within_empty_method_parentheses = false +ij_java_spaces_around_additive_operators = true +ij_java_spaces_around_assignment_operators = true +ij_java_spaces_around_bitwise_operators = true +ij_java_spaces_around_equality_operators = true +ij_java_spaces_around_lambda_arrow = true +ij_java_spaces_around_logical_operators = true +ij_java_spaces_around_method_ref_dbl_colon = false +ij_java_spaces_around_multiplicative_operators = true +ij_java_spaces_around_relational_operators = true +ij_java_spaces_around_shift_operators = true +ij_java_spaces_around_type_bounds_in_type_parameters = true +ij_java_spaces_around_unary_operator = false +ij_java_spaces_within_angle_brackets = false +ij_java_spaces_within_annotation_parentheses = false +ij_java_spaces_within_array_initializer_braces = false +ij_java_spaces_within_braces = false +ij_java_spaces_within_brackets = false +ij_java_spaces_within_cast_parentheses = false +ij_java_spaces_within_catch_parentheses = false +ij_java_spaces_within_for_parentheses = false +ij_java_spaces_within_if_parentheses = false +ij_java_spaces_within_method_call_parentheses = false +ij_java_spaces_within_method_parentheses = false +ij_java_spaces_within_parentheses = false +ij_java_spaces_within_record_header = false +ij_java_spaces_within_switch_parentheses = false +ij_java_spaces_within_synchronized_parentheses = false +ij_java_spaces_within_try_parentheses = false +ij_java_spaces_within_while_parentheses = false +ij_java_special_else_if_treatment = true +ij_java_subclass_name_suffix = Impl +ij_java_ternary_operation_signs_on_next_line = false +ij_java_ternary_operation_wrap = off +ij_java_test_name_suffix = Test +ij_java_throws_keyword_wrap = off +ij_java_throws_list_wrap = off +ij_java_use_external_annotations = false +ij_java_use_fq_class_names = false +ij_java_use_relative_indents = false +ij_java_use_single_class_imports = true +ij_java_variable_annotation_wrap = off +ij_java_visibility = public +ij_java_while_brace_force = never +ij_java_while_on_new_line = false +ij_java_wrap_comments = false +ij_java_wrap_first_method_in_call_chain = false +ij_java_wrap_long_lines = false + +[*.less] +indent_size = 2 +ij_less_align_closing_brace_with_properties = false +ij_less_blank_lines_around_nested_selector = 1 +ij_less_blank_lines_between_blocks = 1 +ij_less_brace_placement = 0 +ij_less_enforce_quotes_on_format = false +ij_less_hex_color_long_format = false +ij_less_hex_color_lower_case = false +ij_less_hex_color_short_format = false +ij_less_hex_color_upper_case = false +ij_less_keep_blank_lines_in_code = 2 +ij_less_keep_indents_on_empty_lines = false +ij_less_keep_single_line_blocks = false +ij_less_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow +ij_less_space_after_colon = true +ij_less_space_before_opening_brace = true +ij_less_use_double_quotes = true +ij_less_value_alignment = 0 + +[*.proto] +indent_size = 2 +tab_width = 2 +ij_continuation_indent_size = 4 +ij_protobuf_keep_blank_lines_in_code = 2 +ij_protobuf_keep_indents_on_empty_lines = false +ij_protobuf_keep_line_breaks = true +ij_protobuf_space_after_comma = true +ij_protobuf_space_before_comma = false +ij_protobuf_spaces_around_assignment_operators = true +ij_protobuf_spaces_within_braces = false +ij_protobuf_spaces_within_brackets = false + +[*.sass] +indent_size = 2 +ij_sass_align_closing_brace_with_properties = false +ij_sass_blank_lines_around_nested_selector = 1 +ij_sass_blank_lines_between_blocks = 1 +ij_sass_brace_placement = 0 +ij_sass_enforce_quotes_on_format = false +ij_sass_hex_color_long_format = false +ij_sass_hex_color_lower_case = false +ij_sass_hex_color_short_format = false +ij_sass_hex_color_upper_case = false +ij_sass_keep_blank_lines_in_code = 2 +ij_sass_keep_indents_on_empty_lines = false +ij_sass_keep_single_line_blocks = false +ij_sass_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow +ij_sass_space_after_colon = true +ij_sass_space_before_opening_brace = true +ij_sass_use_double_quotes = true +ij_sass_value_alignment = 0 + +[*.scss] +indent_size = 2 +ij_scss_align_closing_brace_with_properties = false +ij_scss_blank_lines_around_nested_selector = 1 +ij_scss_blank_lines_between_blocks = 1 +ij_scss_brace_placement = 0 +ij_scss_enforce_quotes_on_format = false +ij_scss_hex_color_long_format = false +ij_scss_hex_color_lower_case = false +ij_scss_hex_color_short_format = false +ij_scss_hex_color_upper_case = false +ij_scss_keep_blank_lines_in_code = 2 +ij_scss_keep_indents_on_empty_lines = false +ij_scss_keep_single_line_blocks = false +ij_scss_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow +ij_scss_space_after_colon = true +ij_scss_space_before_opening_brace = true +ij_scss_use_double_quotes = true +ij_scss_value_alignment = 0 + +[*.styl] +indent_size = 2 +ij_stylus_align_closing_brace_with_properties = false +ij_stylus_blank_lines_around_nested_selector = 1 +ij_stylus_blank_lines_between_blocks = 1 +ij_stylus_brace_placement = 0 +ij_stylus_enforce_quotes_on_format = false +ij_stylus_hex_color_long_format = false +ij_stylus_hex_color_lower_case = false +ij_stylus_hex_color_short_format = false +ij_stylus_hex_color_upper_case = false +ij_stylus_keep_blank_lines_in_code = 2 +ij_stylus_keep_indents_on_empty_lines = false +ij_stylus_keep_single_line_blocks = false +ij_stylus_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow +ij_stylus_space_after_colon = true +ij_stylus_space_before_opening_brace = true +ij_stylus_use_double_quotes = true +ij_stylus_value_alignment = 0 + +[.editorconfig] +ij_editorconfig_align_group_field_declarations = false +ij_editorconfig_space_after_colon = false +ij_editorconfig_space_after_comma = true +ij_editorconfig_space_before_colon = false +ij_editorconfig_space_before_comma = false +ij_editorconfig_spaces_around_assignment_operators = true + +[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.pom,*.rng,*.tld,*.wadl,*.wsdd,*.wsdl,*.xjb,*.xml,*.xsd,*.xsl,*.xslt,*.xul}] +ij_xml_align_attributes = true +ij_xml_align_text = false +ij_xml_attribute_wrap = normal +ij_xml_block_comment_at_first_column = true +ij_xml_keep_blank_lines = 2 +ij_xml_keep_indents_on_empty_lines = false +ij_xml_keep_line_breaks = true +ij_xml_keep_line_breaks_in_text = true +ij_xml_keep_whitespaces = false +ij_xml_keep_whitespaces_around_cdata = preserve +ij_xml_keep_whitespaces_inside_cdata = false +ij_xml_line_comment_at_first_column = true +ij_xml_space_after_tag_name = false +ij_xml_space_around_equals_in_attribute = false +ij_xml_space_inside_empty_tag = false +ij_xml_text_wrap = normal +ij_xml_use_custom_settings = false + +[{*.ats,*.ts}] +ij_continuation_indent_size = 4 +ij_typescript_align_imports = false +ij_typescript_align_multiline_array_initializer_expression = false +ij_typescript_align_multiline_binary_operation = false +ij_typescript_align_multiline_chained_methods = false +ij_typescript_align_multiline_extends_list = false +ij_typescript_align_multiline_for = true +ij_typescript_align_multiline_parameters = true +ij_typescript_align_multiline_parameters_in_calls = false +ij_typescript_align_multiline_ternary_operation = false +ij_typescript_align_object_properties = 0 +ij_typescript_align_union_types = false +ij_typescript_align_var_statements = 0 +ij_typescript_array_initializer_new_line_after_left_brace = false +ij_typescript_array_initializer_right_brace_on_new_line = false +ij_typescript_array_initializer_wrap = off +ij_typescript_assignment_wrap = off +ij_typescript_binary_operation_sign_on_next_line = false +ij_typescript_binary_operation_wrap = off +ij_typescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** +ij_typescript_blank_lines_after_imports = 1 +ij_typescript_blank_lines_around_class = 1 +ij_typescript_blank_lines_around_field = 0 +ij_typescript_blank_lines_around_field_in_interface = 0 +ij_typescript_blank_lines_around_function = 1 +ij_typescript_blank_lines_around_method = 1 +ij_typescript_blank_lines_around_method_in_interface = 1 +ij_typescript_block_brace_style = end_of_line +ij_typescript_call_parameters_new_line_after_left_paren = false +ij_typescript_call_parameters_right_paren_on_new_line = false +ij_typescript_call_parameters_wrap = off +ij_typescript_catch_on_new_line = false +ij_typescript_chained_call_dot_on_new_line = true +ij_typescript_class_brace_style = end_of_line +ij_typescript_comma_on_new_line = false +ij_typescript_do_while_brace_force = never +ij_typescript_else_on_new_line = false +ij_typescript_enforce_trailing_comma = keep +ij_typescript_extends_keyword_wrap = off +ij_typescript_extends_list_wrap = off +ij_typescript_field_prefix = _ +ij_typescript_file_name_style = relaxed +ij_typescript_finally_on_new_line = false +ij_typescript_for_brace_force = never +ij_typescript_for_statement_new_line_after_left_paren = false +ij_typescript_for_statement_right_paren_on_new_line = false +ij_typescript_for_statement_wrap = off +ij_typescript_force_quote_style = false +ij_typescript_force_semicolon_style = false +ij_typescript_function_expression_brace_style = end_of_line +ij_typescript_if_brace_force = never +ij_typescript_import_merge_members = global +ij_typescript_import_prefer_absolute_path = global +ij_typescript_import_sort_members = true +ij_typescript_import_sort_module_name = false +ij_typescript_import_use_node_resolution = true +ij_typescript_imports_wrap = on_every_item +ij_typescript_indent_case_from_switch = true +ij_typescript_indent_chained_calls = true +ij_typescript_indent_package_children = 0 +ij_typescript_jsdoc_include_types = false +ij_typescript_jsx_attribute_value = braces +ij_typescript_keep_blank_lines_in_code = 2 +ij_typescript_keep_first_column_comment = true +ij_typescript_keep_indents_on_empty_lines = false +ij_typescript_keep_line_breaks = true +ij_typescript_keep_simple_blocks_in_one_line = false +ij_typescript_keep_simple_methods_in_one_line = false +ij_typescript_line_comment_add_space = true +ij_typescript_line_comment_at_first_column = false +ij_typescript_method_brace_style = end_of_line +ij_typescript_method_call_chain_wrap = off +ij_typescript_method_parameters_new_line_after_left_paren = false +ij_typescript_method_parameters_right_paren_on_new_line = false +ij_typescript_method_parameters_wrap = off +ij_typescript_object_literal_wrap = on_every_item +ij_typescript_parentheses_expression_new_line_after_left_paren = false +ij_typescript_parentheses_expression_right_paren_on_new_line = false +ij_typescript_place_assignment_sign_on_next_line = false +ij_typescript_prefer_as_type_cast = false +ij_typescript_prefer_explicit_types_function_expression_returns = false +ij_typescript_prefer_explicit_types_function_returns = false +ij_typescript_prefer_explicit_types_vars_fields = false +ij_typescript_prefer_parameters_wrap = false +ij_typescript_reformat_c_style_comments = false +ij_typescript_space_after_colon = true +ij_typescript_space_after_comma = true +ij_typescript_space_after_dots_in_rest_parameter = false +ij_typescript_space_after_generator_mult = true +ij_typescript_space_after_property_colon = true +ij_typescript_space_after_quest = true +ij_typescript_space_after_type_colon = true +ij_typescript_space_after_unary_not = false +ij_typescript_space_before_async_arrow_lparen = true +ij_typescript_space_before_catch_keyword = true +ij_typescript_space_before_catch_left_brace = true +ij_typescript_space_before_catch_parentheses = true +ij_typescript_space_before_class_lbrace = true +ij_typescript_space_before_class_left_brace = true +ij_typescript_space_before_colon = true +ij_typescript_space_before_comma = false +ij_typescript_space_before_do_left_brace = true +ij_typescript_space_before_else_keyword = true +ij_typescript_space_before_else_left_brace = true +ij_typescript_space_before_finally_keyword = true +ij_typescript_space_before_finally_left_brace = true +ij_typescript_space_before_for_left_brace = true +ij_typescript_space_before_for_parentheses = true +ij_typescript_space_before_for_semicolon = false +ij_typescript_space_before_function_left_parenth = true +ij_typescript_space_before_generator_mult = false +ij_typescript_space_before_if_left_brace = true +ij_typescript_space_before_if_parentheses = true +ij_typescript_space_before_method_call_parentheses = false +ij_typescript_space_before_method_left_brace = true +ij_typescript_space_before_method_parentheses = false +ij_typescript_space_before_property_colon = false +ij_typescript_space_before_quest = true +ij_typescript_space_before_switch_left_brace = true +ij_typescript_space_before_switch_parentheses = true +ij_typescript_space_before_try_left_brace = true +ij_typescript_space_before_type_colon = false +ij_typescript_space_before_unary_not = false +ij_typescript_space_before_while_keyword = true +ij_typescript_space_before_while_left_brace = true +ij_typescript_space_before_while_parentheses = true +ij_typescript_spaces_around_additive_operators = true +ij_typescript_spaces_around_arrow_function_operator = true +ij_typescript_spaces_around_assignment_operators = true +ij_typescript_spaces_around_bitwise_operators = true +ij_typescript_spaces_around_equality_operators = true +ij_typescript_spaces_around_logical_operators = true +ij_typescript_spaces_around_multiplicative_operators = true +ij_typescript_spaces_around_relational_operators = true +ij_typescript_spaces_around_shift_operators = true +ij_typescript_spaces_around_unary_operator = false +ij_typescript_spaces_within_array_initializer_brackets = false +ij_typescript_spaces_within_brackets = false +ij_typescript_spaces_within_catch_parentheses = false +ij_typescript_spaces_within_for_parentheses = false +ij_typescript_spaces_within_if_parentheses = false +ij_typescript_spaces_within_imports = false +ij_typescript_spaces_within_interpolation_expressions = false +ij_typescript_spaces_within_method_call_parentheses = false +ij_typescript_spaces_within_method_parentheses = false +ij_typescript_spaces_within_object_literal_braces = false +ij_typescript_spaces_within_object_type_braces = true +ij_typescript_spaces_within_parentheses = false +ij_typescript_spaces_within_switch_parentheses = false +ij_typescript_spaces_within_type_assertion = false +ij_typescript_spaces_within_union_types = true +ij_typescript_spaces_within_while_parentheses = false +ij_typescript_special_else_if_treatment = true +ij_typescript_ternary_operation_signs_on_next_line = false +ij_typescript_ternary_operation_wrap = off +ij_typescript_union_types_wrap = on_every_item +ij_typescript_use_chained_calls_group_indents = false +ij_typescript_use_double_quotes = true +ij_typescript_use_explicit_js_extension = global +ij_typescript_use_path_mapping = always +ij_typescript_use_public_modifier = false +ij_typescript_use_semicolon_after_statement = true +ij_typescript_var_declaration_wrap = normal +ij_typescript_while_brace_force = never +ij_typescript_while_on_new_line = false +ij_typescript_wrap_comments = false + +[{*.bash,*.sh,*.zsh}] +indent_size = 2 +tab_width = 2 +ij_shell_binary_ops_start_line = false +ij_shell_keep_column_alignment_padding = false +ij_shell_minify_program = false +ij_shell_redirect_followed_by_space = false +ij_shell_switch_cases_indented = false +ij_shell_use_unix_line_separator = true + +[{*.cjs,*.js}] +ij_continuation_indent_size = 4 +ij_javascript_align_imports = false +ij_javascript_align_multiline_array_initializer_expression = false +ij_javascript_align_multiline_binary_operation = false +ij_javascript_align_multiline_chained_methods = false +ij_javascript_align_multiline_extends_list = false +ij_javascript_align_multiline_for = true +ij_javascript_align_multiline_parameters = true +ij_javascript_align_multiline_parameters_in_calls = false +ij_javascript_align_multiline_ternary_operation = false +ij_javascript_align_object_properties = 0 +ij_javascript_align_union_types = false +ij_javascript_align_var_statements = 0 +ij_javascript_array_initializer_new_line_after_left_brace = false +ij_javascript_array_initializer_right_brace_on_new_line = false +ij_javascript_array_initializer_wrap = off +ij_javascript_assignment_wrap = off +ij_javascript_binary_operation_sign_on_next_line = false +ij_javascript_binary_operation_wrap = off +ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** +ij_javascript_blank_lines_after_imports = 1 +ij_javascript_blank_lines_around_class = 1 +ij_javascript_blank_lines_around_field = 0 +ij_javascript_blank_lines_around_function = 1 +ij_javascript_blank_lines_around_method = 1 +ij_javascript_block_brace_style = end_of_line +ij_javascript_call_parameters_new_line_after_left_paren = false +ij_javascript_call_parameters_right_paren_on_new_line = false +ij_javascript_call_parameters_wrap = off +ij_javascript_catch_on_new_line = false +ij_javascript_chained_call_dot_on_new_line = true +ij_javascript_class_brace_style = end_of_line +ij_javascript_comma_on_new_line = false +ij_javascript_do_while_brace_force = never +ij_javascript_else_on_new_line = false +ij_javascript_enforce_trailing_comma = keep +ij_javascript_extends_keyword_wrap = off +ij_javascript_extends_list_wrap = off +ij_javascript_field_prefix = _ +ij_javascript_file_name_style = relaxed +ij_javascript_finally_on_new_line = false +ij_javascript_for_brace_force = never +ij_javascript_for_statement_new_line_after_left_paren = false +ij_javascript_for_statement_right_paren_on_new_line = false +ij_javascript_for_statement_wrap = off +ij_javascript_force_quote_style = false +ij_javascript_force_semicolon_style = false +ij_javascript_function_expression_brace_style = end_of_line +ij_javascript_if_brace_force = never +ij_javascript_import_merge_members = global +ij_javascript_import_prefer_absolute_path = global +ij_javascript_import_sort_members = true +ij_javascript_import_sort_module_name = false +ij_javascript_import_use_node_resolution = true +ij_javascript_imports_wrap = on_every_item +ij_javascript_indent_case_from_switch = true +ij_javascript_indent_chained_calls = true +ij_javascript_indent_package_children = 0 +ij_javascript_jsx_attribute_value = braces +ij_javascript_keep_blank_lines_in_code = 2 +ij_javascript_keep_first_column_comment = true +ij_javascript_keep_indents_on_empty_lines = false +ij_javascript_keep_line_breaks = true +ij_javascript_keep_simple_blocks_in_one_line = false +ij_javascript_keep_simple_methods_in_one_line = false +ij_javascript_line_comment_add_space = true +ij_javascript_line_comment_at_first_column = false +ij_javascript_method_brace_style = end_of_line +ij_javascript_method_call_chain_wrap = off +ij_javascript_method_parameters_new_line_after_left_paren = false +ij_javascript_method_parameters_right_paren_on_new_line = false +ij_javascript_method_parameters_wrap = off +ij_javascript_object_literal_wrap = on_every_item +ij_javascript_parentheses_expression_new_line_after_left_paren = false +ij_javascript_parentheses_expression_right_paren_on_new_line = false +ij_javascript_place_assignment_sign_on_next_line = false +ij_javascript_prefer_as_type_cast = false +ij_javascript_prefer_explicit_types_function_expression_returns = false +ij_javascript_prefer_explicit_types_function_returns = false +ij_javascript_prefer_explicit_types_vars_fields = false +ij_javascript_prefer_parameters_wrap = false +ij_javascript_reformat_c_style_comments = false +ij_javascript_space_after_colon = true +ij_javascript_space_after_comma = true +ij_javascript_space_after_dots_in_rest_parameter = false +ij_javascript_space_after_generator_mult = true +ij_javascript_space_after_property_colon = true +ij_javascript_space_after_quest = true +ij_javascript_space_after_type_colon = true +ij_javascript_space_after_unary_not = false +ij_javascript_space_before_async_arrow_lparen = true +ij_javascript_space_before_catch_keyword = true +ij_javascript_space_before_catch_left_brace = true +ij_javascript_space_before_catch_parentheses = true +ij_javascript_space_before_class_lbrace = true +ij_javascript_space_before_class_left_brace = true +ij_javascript_space_before_colon = true +ij_javascript_space_before_comma = false +ij_javascript_space_before_do_left_brace = true +ij_javascript_space_before_else_keyword = true +ij_javascript_space_before_else_left_brace = true +ij_javascript_space_before_finally_keyword = true +ij_javascript_space_before_finally_left_brace = true +ij_javascript_space_before_for_left_brace = true +ij_javascript_space_before_for_parentheses = true +ij_javascript_space_before_for_semicolon = false +ij_javascript_space_before_function_left_parenth = true +ij_javascript_space_before_generator_mult = false +ij_javascript_space_before_if_left_brace = true +ij_javascript_space_before_if_parentheses = true +ij_javascript_space_before_method_call_parentheses = false +ij_javascript_space_before_method_left_brace = true +ij_javascript_space_before_method_parentheses = false +ij_javascript_space_before_property_colon = false +ij_javascript_space_before_quest = true +ij_javascript_space_before_switch_left_brace = true +ij_javascript_space_before_switch_parentheses = true +ij_javascript_space_before_try_left_brace = true +ij_javascript_space_before_type_colon = false +ij_javascript_space_before_unary_not = false +ij_javascript_space_before_while_keyword = true +ij_javascript_space_before_while_left_brace = true +ij_javascript_space_before_while_parentheses = true +ij_javascript_spaces_around_additive_operators = true +ij_javascript_spaces_around_arrow_function_operator = true +ij_javascript_spaces_around_assignment_operators = true +ij_javascript_spaces_around_bitwise_operators = true +ij_javascript_spaces_around_equality_operators = true +ij_javascript_spaces_around_logical_operators = true +ij_javascript_spaces_around_multiplicative_operators = true +ij_javascript_spaces_around_relational_operators = true +ij_javascript_spaces_around_shift_operators = true +ij_javascript_spaces_around_unary_operator = false +ij_javascript_spaces_within_array_initializer_brackets = false +ij_javascript_spaces_within_brackets = false +ij_javascript_spaces_within_catch_parentheses = false +ij_javascript_spaces_within_for_parentheses = false +ij_javascript_spaces_within_if_parentheses = false +ij_javascript_spaces_within_imports = false +ij_javascript_spaces_within_interpolation_expressions = false +ij_javascript_spaces_within_method_call_parentheses = false +ij_javascript_spaces_within_method_parentheses = false +ij_javascript_spaces_within_object_literal_braces = false +ij_javascript_spaces_within_object_type_braces = true +ij_javascript_spaces_within_parentheses = false +ij_javascript_spaces_within_switch_parentheses = false +ij_javascript_spaces_within_type_assertion = false +ij_javascript_spaces_within_union_types = true +ij_javascript_spaces_within_while_parentheses = false +ij_javascript_special_else_if_treatment = true +ij_javascript_ternary_operation_signs_on_next_line = false +ij_javascript_ternary_operation_wrap = off +ij_javascript_union_types_wrap = on_every_item +ij_javascript_use_chained_calls_group_indents = false +ij_javascript_use_double_quotes = true +ij_javascript_use_explicit_js_extension = global +ij_javascript_use_path_mapping = always +ij_javascript_use_public_modifier = false +ij_javascript_use_semicolon_after_statement = true +ij_javascript_var_declaration_wrap = normal +ij_javascript_while_brace_force = never +ij_javascript_while_on_new_line = false +ij_javascript_wrap_comments = false + +[{*.ft,*.vm,*.vsl}] +ij_vtl_keep_indents_on_empty_lines = false + +[{*.gant,*.gradle,*.groovy,*.gson,*.gy}] +ij_groovy_align_group_field_declarations = false +ij_groovy_align_multiline_array_initializer_expression = false +ij_groovy_align_multiline_assignment = false +ij_groovy_align_multiline_binary_operation = false +ij_groovy_align_multiline_chained_methods = false +ij_groovy_align_multiline_extends_list = false +ij_groovy_align_multiline_for = true +ij_groovy_align_multiline_list_or_map = true +ij_groovy_align_multiline_method_parentheses = false +ij_groovy_align_multiline_parameters = true +ij_groovy_align_multiline_parameters_in_calls = false +ij_groovy_align_multiline_resources = true +ij_groovy_align_multiline_ternary_operation = false +ij_groovy_align_multiline_throws_list = false +ij_groovy_align_named_args_in_map = true +ij_groovy_align_throws_keyword = false +ij_groovy_array_initializer_new_line_after_left_brace = false +ij_groovy_array_initializer_right_brace_on_new_line = false +ij_groovy_array_initializer_wrap = off +ij_groovy_assert_statement_wrap = off +ij_groovy_assignment_wrap = off +ij_groovy_binary_operation_wrap = off +ij_groovy_blank_lines_after_class_header = 0 +ij_groovy_blank_lines_after_imports = 1 +ij_groovy_blank_lines_after_package = 1 +ij_groovy_blank_lines_around_class = 1 +ij_groovy_blank_lines_around_field = 0 +ij_groovy_blank_lines_around_field_in_interface = 0 +ij_groovy_blank_lines_around_method = 1 +ij_groovy_blank_lines_around_method_in_interface = 1 +ij_groovy_blank_lines_before_imports = 1 +ij_groovy_blank_lines_before_method_body = 0 +ij_groovy_blank_lines_before_package = 0 +ij_groovy_block_brace_style = end_of_line +ij_groovy_block_comment_at_first_column = true +ij_groovy_call_parameters_new_line_after_left_paren = false +ij_groovy_call_parameters_right_paren_on_new_line = false +ij_groovy_call_parameters_wrap = off +ij_groovy_catch_on_new_line = false +ij_groovy_class_annotation_wrap = split_into_lines +ij_groovy_class_brace_style = end_of_line +ij_groovy_class_count_to_use_import_on_demand = 5 +ij_groovy_do_while_brace_force = never +ij_groovy_else_on_new_line = false +ij_groovy_enum_constants_wrap = off +ij_groovy_extends_keyword_wrap = off +ij_groovy_extends_list_wrap = off +ij_groovy_field_annotation_wrap = split_into_lines +ij_groovy_finally_on_new_line = false +ij_groovy_for_brace_force = never +ij_groovy_for_statement_new_line_after_left_paren = false +ij_groovy_for_statement_right_paren_on_new_line = false +ij_groovy_for_statement_wrap = off +ij_groovy_if_brace_force = never +ij_groovy_import_annotation_wrap = 2 +ij_groovy_imports_layout = *,|,javax.**,java.**,|,$* +ij_groovy_indent_case_from_switch = true +ij_groovy_indent_label_blocks = true +ij_groovy_insert_inner_class_imports = false +ij_groovy_keep_blank_lines_before_right_brace = 2 +ij_groovy_keep_blank_lines_in_code = 2 +ij_groovy_keep_blank_lines_in_declarations = 2 +ij_groovy_keep_control_statement_in_one_line = true +ij_groovy_keep_first_column_comment = true +ij_groovy_keep_indents_on_empty_lines = false +ij_groovy_keep_line_breaks = true +ij_groovy_keep_multiple_expressions_in_one_line = false +ij_groovy_keep_simple_blocks_in_one_line = false +ij_groovy_keep_simple_classes_in_one_line = true +ij_groovy_keep_simple_lambdas_in_one_line = true +ij_groovy_keep_simple_methods_in_one_line = true +ij_groovy_label_indent_absolute = false +ij_groovy_label_indent_size = 0 +ij_groovy_lambda_brace_style = end_of_line +ij_groovy_layout_static_imports_separately = true +ij_groovy_line_comment_add_space = false +ij_groovy_line_comment_at_first_column = true +ij_groovy_method_annotation_wrap = split_into_lines +ij_groovy_method_brace_style = end_of_line +ij_groovy_method_call_chain_wrap = off +ij_groovy_method_parameters_new_line_after_left_paren = false +ij_groovy_method_parameters_right_paren_on_new_line = false +ij_groovy_method_parameters_wrap = off +ij_groovy_modifier_list_wrap = false +ij_groovy_names_count_to_use_import_on_demand = 3 +ij_groovy_parameter_annotation_wrap = off +ij_groovy_parentheses_expression_new_line_after_left_paren = false +ij_groovy_parentheses_expression_right_paren_on_new_line = false +ij_groovy_prefer_parameters_wrap = false +ij_groovy_resource_list_new_line_after_left_paren = false +ij_groovy_resource_list_right_paren_on_new_line = false +ij_groovy_resource_list_wrap = off +ij_groovy_space_after_assert_separator = true +ij_groovy_space_after_colon = true +ij_groovy_space_after_comma = true +ij_groovy_space_after_comma_in_type_arguments = true +ij_groovy_space_after_for_semicolon = true +ij_groovy_space_after_quest = true +ij_groovy_space_after_type_cast = true +ij_groovy_space_before_annotation_parameter_list = false +ij_groovy_space_before_array_initializer_left_brace = false +ij_groovy_space_before_assert_separator = false +ij_groovy_space_before_catch_keyword = true +ij_groovy_space_before_catch_left_brace = true +ij_groovy_space_before_catch_parentheses = true +ij_groovy_space_before_class_left_brace = true +ij_groovy_space_before_closure_left_brace = true +ij_groovy_space_before_colon = true +ij_groovy_space_before_comma = false +ij_groovy_space_before_do_left_brace = true +ij_groovy_space_before_else_keyword = true +ij_groovy_space_before_else_left_brace = true +ij_groovy_space_before_finally_keyword = true +ij_groovy_space_before_finally_left_brace = true +ij_groovy_space_before_for_left_brace = true +ij_groovy_space_before_for_parentheses = true +ij_groovy_space_before_for_semicolon = false +ij_groovy_space_before_if_left_brace = true +ij_groovy_space_before_if_parentheses = true +ij_groovy_space_before_method_call_parentheses = false +ij_groovy_space_before_method_left_brace = true +ij_groovy_space_before_method_parentheses = false +ij_groovy_space_before_quest = true +ij_groovy_space_before_switch_left_brace = true +ij_groovy_space_before_switch_parentheses = true +ij_groovy_space_before_synchronized_left_brace = true +ij_groovy_space_before_synchronized_parentheses = true +ij_groovy_space_before_try_left_brace = true +ij_groovy_space_before_try_parentheses = true +ij_groovy_space_before_while_keyword = true +ij_groovy_space_before_while_left_brace = true +ij_groovy_space_before_while_parentheses = true +ij_groovy_space_in_named_argument = true +ij_groovy_space_in_named_argument_before_colon = false +ij_groovy_space_within_empty_array_initializer_braces = false +ij_groovy_space_within_empty_method_call_parentheses = false +ij_groovy_spaces_around_additive_operators = true +ij_groovy_spaces_around_assignment_operators = true +ij_groovy_spaces_around_bitwise_operators = true +ij_groovy_spaces_around_equality_operators = true +ij_groovy_spaces_around_lambda_arrow = true +ij_groovy_spaces_around_logical_operators = true +ij_groovy_spaces_around_multiplicative_operators = true +ij_groovy_spaces_around_regex_operators = true +ij_groovy_spaces_around_relational_operators = true +ij_groovy_spaces_around_shift_operators = true +ij_groovy_spaces_within_annotation_parentheses = false +ij_groovy_spaces_within_array_initializer_braces = false +ij_groovy_spaces_within_braces = true +ij_groovy_spaces_within_brackets = false +ij_groovy_spaces_within_cast_parentheses = false +ij_groovy_spaces_within_catch_parentheses = false +ij_groovy_spaces_within_for_parentheses = false +ij_groovy_spaces_within_gstring_injection_braces = false +ij_groovy_spaces_within_if_parentheses = false +ij_groovy_spaces_within_list_or_map = false +ij_groovy_spaces_within_method_call_parentheses = false +ij_groovy_spaces_within_method_parentheses = false +ij_groovy_spaces_within_parentheses = false +ij_groovy_spaces_within_switch_parentheses = false +ij_groovy_spaces_within_synchronized_parentheses = false +ij_groovy_spaces_within_try_parentheses = false +ij_groovy_spaces_within_tuple_expression = false +ij_groovy_spaces_within_while_parentheses = false +ij_groovy_special_else_if_treatment = true +ij_groovy_ternary_operation_wrap = off +ij_groovy_throws_keyword_wrap = off +ij_groovy_throws_list_wrap = off +ij_groovy_use_flying_geese_braces = false +ij_groovy_use_fq_class_names = false +ij_groovy_use_fq_class_names_in_javadoc = true +ij_groovy_use_relative_indents = false +ij_groovy_use_single_class_imports = true +ij_groovy_variable_annotation_wrap = off +ij_groovy_while_brace_force = never +ij_groovy_while_on_new_line = false +ij_groovy_wrap_chain_calls_after_dot = false +ij_groovy_wrap_long_lines = false + +[{*.gradle.kts,*.kt,*.kts,*.main.kts,*.space.kts}] +ij_kotlin_align_in_columns_case_branch = false +ij_kotlin_align_multiline_binary_operation = false +ij_kotlin_align_multiline_extends_list = false +ij_kotlin_align_multiline_method_parentheses = false +ij_kotlin_align_multiline_parameters = true +ij_kotlin_align_multiline_parameters_in_calls = false +ij_kotlin_allow_trailing_comma = false +ij_kotlin_allow_trailing_comma_on_call_site = false +ij_kotlin_assignment_wrap = normal +ij_kotlin_blank_lines_after_class_header = 0 +ij_kotlin_blank_lines_around_block_when_branches = 0 +ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 +ij_kotlin_block_comment_at_first_column = true +ij_kotlin_call_parameters_new_line_after_left_paren = true +ij_kotlin_call_parameters_right_paren_on_new_line = true +ij_kotlin_call_parameters_wrap = on_every_item +ij_kotlin_catch_on_new_line = false +ij_kotlin_class_annotation_wrap = split_into_lines +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_continuation_indent_for_chained_calls = false +ij_kotlin_continuation_indent_for_expression_bodies = false +ij_kotlin_continuation_indent_in_argument_lists = false +ij_kotlin_continuation_indent_in_elvis = false +ij_kotlin_continuation_indent_in_if_conditions = false +ij_kotlin_continuation_indent_in_parameter_lists = false +ij_kotlin_continuation_indent_in_supertype_lists = false +ij_kotlin_else_on_new_line = false +ij_kotlin_enum_constants_wrap = off +ij_kotlin_extends_list_wrap = normal +ij_kotlin_field_annotation_wrap = split_into_lines +ij_kotlin_finally_on_new_line = false +ij_kotlin_if_rparen_on_new_line = true +ij_kotlin_import_nested_classes = false +ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^ +ij_kotlin_insert_whitespaces_in_simple_one_line_method = true +ij_kotlin_keep_blank_lines_before_right_brace = 2 +ij_kotlin_keep_blank_lines_in_code = 2 +ij_kotlin_keep_blank_lines_in_declarations = 2 +ij_kotlin_keep_first_column_comment = true +ij_kotlin_keep_indents_on_empty_lines = false +ij_kotlin_keep_line_breaks = true +ij_kotlin_lbrace_on_next_line = false +ij_kotlin_line_comment_add_space = false +ij_kotlin_line_comment_at_first_column = true +ij_kotlin_method_annotation_wrap = split_into_lines +ij_kotlin_method_call_chain_wrap = normal +ij_kotlin_method_parameters_new_line_after_left_paren = true +ij_kotlin_method_parameters_right_paren_on_new_line = true +ij_kotlin_method_parameters_wrap = on_every_item +ij_kotlin_name_count_to_use_star_import = 5 +ij_kotlin_name_count_to_use_star_import_for_members = 3 +ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**,io.ktor.** +ij_kotlin_parameter_annotation_wrap = off +ij_kotlin_space_after_comma = true +ij_kotlin_space_after_extend_colon = true +ij_kotlin_space_after_type_colon = true +ij_kotlin_space_before_catch_parentheses = true +ij_kotlin_space_before_comma = false +ij_kotlin_space_before_extend_colon = true +ij_kotlin_space_before_for_parentheses = true +ij_kotlin_space_before_if_parentheses = true +ij_kotlin_space_before_lambda_arrow = true +ij_kotlin_space_before_type_colon = false +ij_kotlin_space_before_when_parentheses = true +ij_kotlin_space_before_while_parentheses = true +ij_kotlin_spaces_around_additive_operators = true +ij_kotlin_spaces_around_assignment_operators = true +ij_kotlin_spaces_around_equality_operators = true +ij_kotlin_spaces_around_function_type_arrow = true +ij_kotlin_spaces_around_logical_operators = true +ij_kotlin_spaces_around_multiplicative_operators = true +ij_kotlin_spaces_around_range = false +ij_kotlin_spaces_around_relational_operators = true +ij_kotlin_spaces_around_unary_operator = false +ij_kotlin_spaces_around_when_arrow = true +ij_kotlin_variable_annotation_wrap = off +ij_kotlin_while_on_new_line = false +ij_kotlin_wrap_elvis_expressions = 1 +ij_kotlin_wrap_expression_body_functions = 1 +ij_kotlin_wrap_first_method_in_call_chain = false + +[{*.har,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.stylelintrc,bowerrc,jest.config}] +indent_size = 2 +ij_json_keep_blank_lines_in_code = 0 +ij_json_keep_indents_on_empty_lines = false +ij_json_keep_line_breaks = true +ij_json_space_after_colon = true +ij_json_space_after_comma = true +ij_json_space_before_colon = true +ij_json_space_before_comma = false +ij_json_spaces_within_braces = false +ij_json_spaces_within_brackets = false +ij_json_wrap_long_lines = false + +[{*.htm,*.html,*.ng,*.sht,*.shtm,*.shtml}] +ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3 +ij_html_align_attributes = true +ij_html_align_text = false +ij_html_attribute_wrap = normal +ij_html_block_comment_at_first_column = true +ij_html_do_not_align_children_of_min_lines = 0 +ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p +ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot +ij_html_enforce_quotes = false +ij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var +ij_html_keep_blank_lines = 2 +ij_html_keep_indents_on_empty_lines = false +ij_html_keep_line_breaks = true +ij_html_keep_line_breaks_in_text = true +ij_html_keep_whitespaces = false +ij_html_keep_whitespaces_inside = span,pre,textarea +ij_html_line_comment_at_first_column = true +ij_html_new_line_after_last_attribute = never +ij_html_new_line_before_first_attribute = never +ij_html_quote_style = double +ij_html_remove_new_line_before_tags = br +ij_html_space_after_tag_name = false +ij_html_space_around_equality_in_attribute = false +ij_html_space_inside_empty_tag = false +ij_html_text_wrap = normal + +[{*.jsf,*.jsp,*.jspf,*.tag,*.tagf,*.xjsp}] +ij_jsp_jsp_prefer_comma_separated_import_list = false +ij_jsp_keep_indents_on_empty_lines = false + +[{*.jspx,*.tagx}] +ij_jspx_keep_indents_on_empty_lines = false + +[{*.markdown,*.md}] +max_line_length = 200 +ij_markdown_force_one_space_after_blockquote_symbol = true +ij_markdown_force_one_space_after_header_symbol = true +ij_markdown_force_one_space_after_list_bullet = true +ij_markdown_force_one_space_between_words = true +ij_markdown_keep_indents_on_empty_lines = false +ij_markdown_max_lines_around_block_elements = 1 +ij_markdown_max_lines_around_header = 1 +ij_markdown_max_lines_between_paragraphs = 1 +ij_markdown_min_lines_around_block_elements = 1 +ij_markdown_min_lines_around_header = 1 +ij_markdown_min_lines_between_paragraphs = 1 + +[{*.pb,*.textproto}] +indent_size = 2 +tab_width = 2 +ij_continuation_indent_size = 4 +ij_prototext_keep_blank_lines_in_code = 2 +ij_prototext_keep_indents_on_empty_lines = false +ij_prototext_keep_line_breaks = true +ij_prototext_space_after_colon = true +ij_prototext_space_after_comma = true +ij_prototext_space_before_colon = false +ij_prototext_space_before_comma = false +ij_prototext_spaces_within_braces = true +ij_prototext_spaces_within_brackets = false + +[{*.properties,spring.handlers,spring.schemas}] +ij_properties_align_group_field_declarations = false +ij_properties_keep_blank_lines = false +ij_properties_key_value_delimiter = equals +ij_properties_spaces_around_key_value_delimiter = false + +[{*.yaml,*.yml}] +indent_size = 2 +ij_yaml_align_values_properties = do_not_align +ij_yaml_autoinsert_sequence_marker = true +ij_yaml_block_mapping_on_new_line = false +ij_yaml_indent_sequence_value = true +ij_yaml_keep_indents_on_empty_lines = false +ij_yaml_keep_line_breaks = true +ij_yaml_sequence_on_new_line = false +ij_yaml_space_before_colon = false +ij_yaml_spaces_within_braces = true +ij_yaml_spaces_within_brackets = true diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 260baa90f6..0000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,28 +0,0 @@ -# 公告 - -提交前请确保你的启动器版本是**最新的开发版**,可以在启动器设置中更换更新通道至开发版获取更新。 - -**如果你在阅读完本公告后仍然希望在此处发布 issue,请将公告内容删去,并按照下面的模板填入相关信息。** - - - -我们发现,对于不经常检查邮箱的人来说,GitHub issues 的反馈效率太低,时隔几个小时甚至一天的情况很多。为了改善反馈效率,并将积极为 HMCL 提供问题反馈和建议的人聚集起来,我们希望在这里反馈的人可以在 HMCL 3 用户 QQ 群:219177735 中反馈信息,**而不在 GitHub Issues 中反馈问题**。 - -如果你希望在 QQ 群中反馈问题,也请将下面的信息表填好直接发在群中,加快我们的沟通速度。 - - - -# 问题提交 - -*完整地填下面的问题提交表对我们很重要,这可以加快我们分析问题原因的速度。* - -* 启动器版本: -* 操作系统: -* Java 版本: -* 错误截图(最好请将整个电脑屏幕的截图发上来): -* 游戏版本(如果是启动通过启动器自带的自动安装功能安装的游戏): -* 对游戏做的修改(是否自行通过安装器安装 Rift 等 API,以及 mod): -* 游戏崩溃报告(如果有): -* 启动器崩溃报告(如果有): -* 启动器日志文件(在启动器设置中打开日志文件夹并将文件夹内所有文件打包发在这里): -* 问题描述(如何触发问题): \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000000..2d29fd3706 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,49 @@ +name: Bug 反馈 | Bug Report +description: + 反馈一个 HMCL 错误。| File a bug report for HMCL. +title: "[Bug] " +labels: bug +body: + - type: markdown + attributes: + value: | + 提交前请确认: + + * 该问题确实是 **HMCL 的错误**,而**不是 Minecraft 非正常退出**,如果你的 Minecraft 非正常退出,请前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。 + * 你的启动器版本是**最新的预览版本**,可以点击 [此处](https://zkitefly.github.io/HMCL-Snapshot-Update/) 下载最新预览版本。 + + 如果你的问题并不属于上述两类,你可以选取另一种 Issue 类型,或者直接前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。 + + Before submitting, please confirm: + + * The issue is indeed **a bug of HMCL**, not **Minecraft abnormal exit**. If your Minecraft exits abnormally, please go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help. + * Your launcher is the **latest nightly build**. You can click [here](https://zkitefly.github.io/HMCL-Snapshot-Update/en) to download the latest nightly build. + + If your issue does not fall into the above two categories, you can choose another type of issue or directly go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help. + - type: textarea + id: bug-report + attributes: + label: 问题描述 | Bug Description + description: | + 请尽可能地详细描述你所遇到的问题,并描述如何重新触发这个问题。 + Please describe the bug you met in as much detail as possible. Additionally, describe the steps to reproduce this bug. + placeholder: | + 1. 点击 HMCL 上的某个按钮 | Click a button named ... + 2. 向下翻页 | Scroll down + 3. ... + validations: + required: true + - type: textarea + id: hmcl-crash-report-or-logs + attributes: + label: 启动器崩溃报告 / 启动器日志文件 | Launcher Crash Report / Launcher Log File + description: | + 如果你的启动器崩溃了,请将崩溃报告填入(或将文件拖入)下方。 + 如果你的启动器没有崩溃,请在遇到问题后**不要退出启动器**,在启动器的 “设置 → 通用 → 调试” 一栏中点击 “导出启动器日志”,并将导出的日志拖到下方的输入栏中。 + **请注意:启动器崩溃报告或日志文件是诊断问题的重要依据,请务必上传!** + + If your launcher crashes, please fill in (or drag the file in) the following input field with the crash report. + If your launcher does not crash, please DO NOT EXIT your launcher, click "Export Launcher Logs" in the "Settings → General → Debug" of the launcher, and drag the exported log to the following input field. + **ATTENTION: The crash report or log file is the key to resolving the bug. Please upload them!** + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..421efa07ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: QQ 群 | QQ Group + url: https://docs.hmcl.net/groups.html + about: Hello Minecraft! Launcher 的官方 QQ 交流群。| The official QQ group of Hello Minecraft! Launcher. + - name: Discord 服务器 | Discord Server + url: https://discord.gg/jVvC7HfM6U + about: Hello Minecraft! Launcher 的官方 Discord 服务器。| The official Discord server of Hello Minecraft! Launcher. + - name: 其他反馈 | Others + url: https://github.com/HMCL-dev/HMCL/discussions/new/choose + about: 通过 Discussions 反馈其他问题。| Report other problems in Discussions. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000000..0ccdb884cf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,35 @@ +name: 新功能 | Feature Request +description: 为 HMCL 提出新功能。| Suggest a new feature or enhancement for HMCL. +title: "[Feature] " +labels: enhancement +body: +- type: markdown + attributes: + value: | + 请确认 Issues 列表无重复的项目。 + Please make sure that no duplicate issues have already been submitted. +- type: textarea + id: summary + attributes: + label: 概述 | Summary + description: | + 请介绍你想加入的新功能。 + Please describe the new feature. + validations: + required: true +- type: textarea + id: reason + attributes: + label: 原因 | Reason + description: | + 请描述该功能带来的好处及原因。 + Please describe why you want to add the feature or enhancement to HMCL. + validations: + required: true +- type: textarea + id: description + attributes: + label: 详情 | Description + description: | + 在这里可以补充描述该功能的具体实现方式或建议。(可选) + Describe implementation details or suggestions here. (Optional) diff --git a/.github/workflows/check-codes.yml b/.github/workflows/check-codes.yml new file mode 100644 index 0000000000..5a27e421d8 --- /dev/null +++ b/.github/workflows/check-codes.yml @@ -0,0 +1,25 @@ +name: Check Codes + +on: + push: + paths: + - '**.java' + - '**.properties' + pull_request: + paths: + - '**.java' + - '**.properties' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + java-package: 'jdk+fx' + - name: Check Codes + run: ./gradlew checkstyle checkTranslations --no-daemon --parallel diff --git a/.github/workflows/check-update.yml b/.github/workflows/check-update.yml new file mode 100644 index 0000000000..a1c5ae4034 --- /dev/null +++ b/.github/workflows/check-update.yml @@ -0,0 +1,122 @@ +name: Check Update + +on: + workflow_dispatch: + schedule: + - cron: '30 * * * *' + +permissions: + contents: write + +jobs: + dev-check-update: + if: ${{ github.repository_owner == 'HMCL-dev' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '25' + - name: Fetch tags + run: git fetch --all --tags + - name: Fetch last version + run: ./gradlew checkUpdateDev --no-daemon --info --stacktrace + - name: Check for existing tags + run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi + - name: Download artifacts + if: ${{ env.continue == 'true' }} + run: | + wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe" + wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha256" + wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar" + wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha256" + wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh" + wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha256" + env: + DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/artifact/HMCL/build/libs + - name: Generate release note + if: ${{ env.continue == 'true' }} + run: | + echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/dev.html" >> RELEASE_NOTE + echo "" >> RELEASE_NOTE + echo "*Notice: changelogs are written in Chinese.*" >> RELEASE_NOTE + echo "" >> RELEASE_NOTE + echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE + echo "| --- | --- |" >> RELEASE_NOTE + echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE + echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE + echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE + env: + GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download + - name: Create release + if: ${{ env.continue == 'true' }} + uses: softprops/action-gh-release@v2 + with: + body_path: RELEASE_NOTE + files: | + HMCL-${{ env.HMCL_VERSION }}.exe + HMCL-${{ env.HMCL_VERSION }}.jar + HMCL-${{ env.HMCL_VERSION }}.sh + target_commitish: ${{ env.HMCL_COMMIT_SHA }} + name: ${{ env.HMCL_TAG_NAME }} + tag_name: ${{ env.HMCL_TAG_NAME }} + prerelease: true + stable-check-update: + if: ${{ github.repository_owner == 'HMCL-dev' }} + needs: dev-check-update + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '25' + - name: Fetch tags + run: git fetch --all --tags + + - name: Fetch last version + run: ./gradlew checkUpdateStable --no-daemon --info --stacktrace + - name: Check for existing tags + run: if ! git tag -l | grep -q "$HMCL_TAG_NAME"; then echo "continue=true" >> $GITHUB_ENV; fi + - name: Download artifacts + if: ${{ env.continue == 'true' }} + run: | + wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe" + wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha256" + wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar" + wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha256" + wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh" + wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha256" + env: + DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL-stable/lastSuccessfulBuild/artifact/HMCL/build/libs + - name: Generate release note + if: ${{ env.continue == 'true' }} + run: | + echo "**This version is a stable version.**" >> RELEASE_NOTE + echo "" >> RELEASE_NOTE + echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/stable.html" >> RELEASE_NOTE + echo "" >> RELEASE_NOTE + echo "*Notice: changelogs are written in Chinese.*" >> RELEASE_NOTE + echo "" >> RELEASE_NOTE + echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE + echo "| --- | --- |" >> RELEASE_NOTE + echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE + echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE + echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE + env: + GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download + - name: Create release + if: ${{ env.continue == 'true' }} + uses: softprops/action-gh-release@v2 + with: + body_path: RELEASE_NOTE + files: | + HMCL-${{ env.HMCL_VERSION }}.exe + HMCL-${{ env.HMCL_VERSION }}.jar + HMCL-${{ env.HMCL_VERSION }}.sh + target_commitish: ${{ env.HMCL_COMMIT_SHA }} + name: ${{ env.HMCL_TAG_NAME }} + tag_name: ${{ env.HMCL_TAG_NAME }} diff --git a/.github/workflows/gitee.yml b/.github/workflows/gitee.yml new file mode 100644 index 0000000000..fb0660c490 --- /dev/null +++ b/.github/workflows/gitee.yml @@ -0,0 +1,21 @@ +name: Sync to Gitee + +on: + push + +jobs: + run: + if: ${{ github.repository_owner == 'HMCL-dev' }} + runs-on: ubuntu-latest + steps: + - name: Mirror GitHub to Gitee + uses: Yikun/hub-mirror-action@v1.4 + with: + src: github/HMCL-dev + dst: gitee/huanghongxun + static_list: 'HMCL' + force_update: true + debug: true + dst_key: ${{ secrets.GITEE_SYNC_BOT_PRIVATE_KEY }} + dst_token: ${{ secrets.GITEE_SYNC_BOT_TOKEN }} + cache_path: /github/workspace/hub-mirror-cache diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000000..e45c6cbc5c --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,50 @@ +name: Java CI + +on: + push: + pull_request: + paths-ignore: + - '**.md' + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + java-package: 'jdk+fx' + - name: Build with Gradle + run: ./gradlew build --no-daemon + env: + MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }} + MICROSOFT_AUTH_SECRET: ${{ secrets.MICROSOFT_AUTH_SECRET }} + CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }} + - name: Get short SHA + run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV + - name: Upload JAR + uses: actions/upload-artifact@v4 + with: + name: HMCL-${{ env.SHORT_SHA }}-jar + path: | + HMCL/build/libs/HMCL-*.jar + HMCL/build/libs/HMCL-*.jar.sha256 + - name: Upload EXE + uses: actions/upload-artifact@v4 + with: + name: HMCL-${{ env.SHORT_SHA }}-exe + path: | + HMCL/build/libs/HMCL-*.exe + HMCL/build/libs/HMCL-*.exe.sha256 + - name: Upload SH + uses: actions/upload-artifact@v4 + with: + name: HMCL-${{ env.SHORT_SHA }}-sh + path: | + HMCL/build/libs/HMCL-*.sh + HMCL/build/libs/HMCL-*.sh.sha256 \ No newline at end of file diff --git a/.gitignore b/.gitignore index c5743c8805..8880c9572a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* +*.hprof .gradle @@ -10,27 +11,51 @@ hs_err_pid* *.2 *.log .mine* +/externalgames NVIDIA -*.json +minecraft-exported-crash-info* +hmcl-exported-logs-* +/.java/ # gradle build /build/ /HMCL/build/ /HMCLCore/build/ +/HMCLBoot/build/ +/minecraft/libraries/HMCLTransformerDiscoveryService/build/ +/minecraft/libraries/HMCLMultiMCBootstrap/build/ +/buildSrc/build/ # idea .idea /out/ /HMCL/out/ /HMCLCore/out/ +/minecraft/libraries/HMCLTransformerDiscoveryService/out/ +/minecraft/libraries/HMCLMultiMCBootstrap/out/ # eclipse /bin/ /HMCL/bin/ /HMCLCore/bin/ +/minecraft/libraries/HMCLTransformerDiscoveryService/bin/ +/minecraft/libraries/HMCLMultiMCBootstrap/bin/ .classpath .project .settings # netbeans .nb-gradle + +*.exe + +# macos +.DS_Store + +# vscode +.vscode/ + +# test +/hmcl.json +/.hmcl.json +/.hmcl/ \ No newline at end of file diff --git a/HMCL/.gitignore b/HMCL/.gitignore new file mode 100644 index 0000000000..e6d51bf051 --- /dev/null +++ b/HMCL/.gitignore @@ -0,0 +1,4 @@ +/data.csv +/data.json +/mod.json +/modpack.json \ No newline at end of file diff --git a/HMCL/.travis.yml b/HMCL/.travis.yml deleted file mode 100644 index 8b9ae134af..0000000000 --- a/HMCL/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: java -jdk: -- oraclejdk8 -branches: - only: - - master -before_install: -- chmod +x gradlew -script: "bash ./gradlew clean build --stacktrace" -before_cache: -- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock -cache: - directories: - - "$HOME/.gradle/caches/" \ No newline at end of file diff --git a/HMCL/build.gradle b/HMCL/build.gradle deleted file mode 100644 index 07b47b5454..0000000000 --- a/HMCL/build.gradle +++ /dev/null @@ -1,160 +0,0 @@ -plugins { - id 'com.github.johnrengelman.shadow' version '4.0.0' - id 'application' -} - -import java.nio.file.FileSystems -import java.security.KeyFactory -import java.security.MessageDigest -import java.security.Signature -import java.security.spec.PKCS8EncodedKeySpec -import java.util.jar.JarFile -import java.util.jar.JarOutputStream -import java.util.jar.Pack200 -import java.util.zip.GZIPOutputStream -import java.util.zip.ZipFile -import java.nio.file.Files - -import org.tukaani.xz.LZMA2Options -import org.tukaani.xz.XZOutputStream - -def buildnumber = System.getenv("BUILD_NUMBER") ?: "SNAPSHOT" -if (System.getenv("BUILD_NUMBER") != null && System.getenv("BUILD_NUMBER_OFFSET") != null) - buildnumber = (Integer.parseInt(System.getenv("BUILD_NUMBER")) - Integer.parseInt(System.getenv("BUILD_NUMBER_OFFSET"))).toString() -def versionroot = System.getenv("VERSION_ROOT") ?: "3.2" -version = versionroot + '.' + buildnumber - -mainClassName = 'org.jackhuang.hmcl.Main' - -dependencies { - compile project(":HMCLCore") - compile rootProject.files("lib/JFoenix.jar") -} - -def digest(String algorithm, byte[] bytes) { - return MessageDigest.getInstance(algorithm).digest(bytes) -} - -def createChecksum(File file) { - def algorithm = "SHA-1" - def suffix = "sha1" - new File(file.parentFile, file.name + "." + suffix).text = digest(algorithm, file.bytes).encodeHex().toString() + "\n" -} - -def attachSignature(File jar) { - def keyLocation = System.getenv("HMCL_SIGNATURE_KEY"); - if (keyLocation == null) - return - def privatekey = KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(new File(keyLocation).bytes)) - - def signer = Signature.getInstance("SHA512withRSA") - signer.initSign(privatekey) - new ZipFile(jar).withCloseable { zip -> - zip.stream() - .sorted(Comparator.comparing { it.name }) - .filter { it.name != "META-INF/hmcl_signature" } - .forEach { - signer.update(digest("SHA-512", it.name.getBytes("UTF-8"))) - signer.update(digest("SHA-512", zip.getInputStream(it).bytes)) - } - } - def signature = signer.sign() - - FileSystems.newFileSystem(URI.create("jar:" + jar.toURI()), [:]).withCloseable { zipfs -> - Files.newOutputStream(zipfs.getPath("META-INF/hmcl_signature")).withCloseable { it << signature } - } -} - -ext.packer = Pack200.newPacker() -packer.properties()["pack.effort"] = "9" -ext.unpacker = Pack200.newUnpacker() - -def repack(File file) { - def packed = new ByteArrayOutputStream() - new JarFile(file).withCloseable { packer.pack(it, packed) } - new JarOutputStream(file.newOutputStream()).withCloseable { unpacker.unpack(new ByteArrayInputStream(packed.toByteArray()), it) } -} - -jar { - manifest { - attributes 'Created-By': 'Copyright(c) 2013-2018 huangyuhui.', - 'Main-Class': mainClassName, - 'Multi-Release': 'true', - 'Implementation-Version': version - } - finalizedBy shadowJar -} - -shadowJar { - classifier = null - - exclude 'META-INF/maven/**' - exclude 'META-INF/NOTICE.txt' - exclude 'META-INF/LICENSE.txt' - - dependencies { - exclude(dependency('org.jetbrains:annotations')) - } - - doLast { - repack(jar.archivePath) - attachSignature(jar.archivePath) - createChecksum(jar.archivePath) - } -} - -def createExecutable(String suffix, String header) { - def output = new File(jar.archivePath.parentFile, jar.archivePath.name[0..-4] + suffix) - output.bytes = new File(project.projectDir, header).bytes - output << jar.archivePath.bytes - createChecksum(output) -} - -processResources { - ext.convertToBSS = { String resource -> - exclude resource - doFirst { - def cssFile = new File(this.projectDir, "src/main/resources/" + resource) - def bssFile = new File(this.projectDir, "build/compiled-resources/" + resource[0..-4] + "bss") - bssFile.parentFile.mkdirs() - exec { - commandLine 'javapackager', '-createbss', '-outdir', bssFile.parent, '-srcfiles', cssFile.path - } - } - } - from "build/compiled-resources" - - convertToBSS "assets/css/root.css" - convertToBSS "assets/css/blue.css" -} - -task makePack(dependsOn: jar) { - ext.outputPath = new File(jar.archivePath.parentFile, jar.archivePath.name[0..-4] + "pack") - doLast { - outputPath.newOutputStream().withCloseable { out -> - new JarFile(jar.archivePath).withCloseable { jarFile -> packer.pack(jarFile, out) } - } - createChecksum(outputPath) - } -} - -task makePackXz(dependsOn: makePack) doLast { - def packXz = new File(makePack.outputPath.parentFile, makePack.outputPath.name + ".xz") - new XZOutputStream(packXz.newOutputStream(), new LZMA2Options(6)).withCloseable { it << makePack.outputPath.bytes } - createChecksum(packXz) -} - -task makePackGz(dependsOn: makePack) doLast { - def packGz = new File(makePack.outputPath.parentFile, makePack.outputPath.name + ".gz") - new GZIPOutputStream(packGz.newOutputStream()).withCloseable { it << makePack.outputPath.bytes } - createChecksum(packGz) -} - - -task makeExecutables(dependsOn: jar) doLast { - createExecutable("exe", "src/main/resources/assets/HMCLauncher.exe") -} - -build.dependsOn makePackXz -build.dependsOn makePackGz -build.dependsOn makeExecutables diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts new file mode 100644 index 0000000000..5b5607e9a9 --- /dev/null +++ b/HMCL/build.gradle.kts @@ -0,0 +1,392 @@ +import org.jackhuang.hmcl.gradle.l10n.CheckTranslations +import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList +import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle +import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate +import org.jackhuang.hmcl.gradle.mod.ParseModDataTask +import java.net.URI +import java.nio.file.FileSystems +import java.nio.file.Files +import java.security.KeyFactory +import java.security.MessageDigest +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import java.util.zip.ZipFile + +plugins { + alias(libs.plugins.shadow) +} + +val isOfficial = System.getenv("HMCL_SIGNATURE_KEY") != null + || (System.getenv("GITHUB_REPOSITORY_OWNER") == "HMCL-dev" && System.getenv("GITHUB_BASE_REF") + .isNullOrEmpty()) + +val buildNumber = System.getenv("BUILD_NUMBER")?.toInt().let { number -> + val offset = System.getenv("BUILD_NUMBER_OFFSET")?.toInt() ?: 0 + if (number != null) { + (number - offset).toString() + } else { + val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7) + val prefix = if (isOfficial) "dev" else "unofficial" + if (!shortCommit.isNullOrEmpty()) "$prefix-$shortCommit" else "SNAPSHOT" + } +} +val versionRoot = System.getenv("VERSION_ROOT") ?: "3.6" +val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" else "unofficial" + +val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: "" +val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: "" +val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: "" + +val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") + +version = "$versionRoot.$buildNumber" + +val embedResources by configurations.registering + +dependencies { + implementation(project(":HMCLCore")) + implementation(project(":HMCLBoot")) + implementation("libs:JFoenix") + implementation(libs.twelvemonkeys.imageio.webp) + implementation(libs.java.info) + + if (launcherExe == null) { + implementation(libs.hmclauncher) + } + + embedResources(libs.authlib.injector) +} + +fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes) + +fun createChecksum(file: File) { + val algorithms = linkedMapOf( + "SHA-1" to "sha1", + "SHA-256" to "sha256", + "SHA-512" to "sha512" + ) + + algorithms.forEach { (algorithm, ext) -> + File(file.parentFile, "${file.name}.$ext").writeText( + digest(algorithm, file.readBytes()).joinToString(separator = "", postfix = "\n") { "%02x".format(it) } + ) + } +} + +fun attachSignature(jar: File) { + val keyLocation = System.getenv("HMCL_SIGNATURE_KEY") + if (keyLocation == null) { + logger.warn("Missing signature key") + return + } + + val privatekey = KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(File(keyLocation).readBytes())) + val signer = Signature.getInstance("SHA512withRSA") + signer.initSign(privatekey) + ZipFile(jar).use { zip -> + zip.stream() + .sorted(Comparator.comparing { it.name }) + .filter { it.name != "META-INF/hmcl_signature" } + .forEach { + signer.update(digest("SHA-512", it.name.toByteArray())) + signer.update(digest("SHA-512", zip.getInputStream(it).readBytes())) + } + } + val signature = signer.sign() + FileSystems.newFileSystem(URI.create("jar:" + jar.toURI()), emptyMap()).use { zipfs -> + Files.newOutputStream(zipfs.getPath("META-INF/hmcl_signature")).use { it.write(signature) } + } +} + +tasks.withType { + sourceCompatibility = "17" + targetCompatibility = "17" +} + +tasks.checkstyleMain { + // Third-party code is not checked + exclude("**/org/jackhuang/hmcl/ui/image/apng/**") +} + +tasks.compileJava { + options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED") +} + +val hmclProperties = buildList { + add("hmcl.version" to project.version.toString()) + System.getenv("GITHUB_SHA")?.let { + add("hmcl.version.hash" to it) + } + add("hmcl.version.type" to versionType) + add("hmcl.microsoft.auth.id" to microsoftAuthId) + add("hmcl.microsoft.auth.secret" to microsoftAuthSecret) + add("hmcl.curseforge.apikey" to curseForgeApiKey) + add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!) +} + +val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties") +val createPropertiesFile by tasks.registering { + outputs.file(hmclPropertiesFile) + hmclProperties.forEach { (k, v) -> inputs.property(k, v) } + + doLast { + val targetFile = hmclPropertiesFile.get().asFile + targetFile.parentFile.mkdir() + targetFile.bufferedWriter().use { + for ((k, v) in hmclProperties) { + it.write("$k=$v\n") + } + } + } +} + +val addOpens = listOf( + "java.base/java.lang", + "java.base/java.lang.reflect", + "java.base/jdk.internal.loader", + "javafx.base/com.sun.javafx.binding", + "javafx.base/com.sun.javafx.event", + "javafx.base/com.sun.javafx.runtime", + "javafx.graphics/javafx.css", + "javafx.graphics/com.sun.javafx.stage", + "javafx.graphics/com.sun.prism", + "javafx.controls/com.sun.javafx.scene.control", + "javafx.controls/com.sun.javafx.scene.control.behavior", + "javafx.controls/javafx.scene.control.skin", + "jdk.attach/sun.tools.attach", +) + +tasks.jar { + enabled = false + dependsOn(tasks["shadowJar"]) +} + +val jarPath = tasks.jar.get().archiveFile.get().asFile + +tasks.shadowJar { + dependsOn(createPropertiesFile) + + archiveClassifier.set(null as String?) + + exclude("**/package-info.class") + exclude("META-INF/maven/**") + + exclude("META-INF/services/javax.imageio.spi.ImageReaderSpi") + exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi") + + listOf( + "aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*", + "*-ppc", "*-ppc64le", "*-s390x", "*-armel", + ).forEach { exclude("com/sun/jna/$it/**") } + + minimize { + exclude(dependency("com.google.code.gson:.*:.*")) + exclude(dependency("net.java.dev.jna:jna:.*")) + exclude(dependency("libs:JFoenix:.*")) + exclude(project(":HMCLBoot")) + } + + manifest.attributes( + "Created-By" to "Copyright(c) 2013-2025 huangyuhui.", + "Implementation-Version" to project.version.toString(), + "Main-Class" to "org.jackhuang.hmcl.Main", + "Multi-Release" to "true", + "Add-Opens" to addOpens.joinToString(" "), + "Enable-Native-Access" to "ALL-UNNAMED" + ) + + if (launcherExe != null) { + into("assets") { + from(file(launcherExe)) + } + } + + doLast { + attachSignature(jarPath) + createChecksum(jarPath) + } +} + +tasks.processResources { + dependsOn(createPropertiesFile) + dependsOn(upsideDownTranslate) + dependsOn(createLocaleNamesResourceBundle) + dependsOn(createLanguageList) + + into("assets/") { + from(hmclPropertiesFile) + from(embedResources) + } + + into("assets/lang") { + from(createLanguageList.map { it.outputFile }) + from(upsideDownTranslate.map { it.outputFile }) + from(createLocaleNamesResourceBundle.map { it.outputDirectory }) + } +} + +val makeExecutables by tasks.registering { + val extensions = listOf("exe", "sh") + + dependsOn(tasks.jar) + + inputs.file(jarPath) + outputs.files(extensions.map { File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + it) }) + + doLast { + val jarContent = jarPath.readBytes() + + ZipFile(jarPath).use { zipFile -> + for (extension in extensions) { + val output = File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + extension) + val entry = zipFile.getEntry("assets/HMCLauncher.$extension") + ?: throw GradleException("HMCLauncher.$extension not found") + + output.outputStream().use { outputStream -> + zipFile.getInputStream(entry).use { it.copyTo(outputStream) } + outputStream.write(jarContent) + } + + createChecksum(output) + } + } + } +} + +tasks.build { + dependsOn(makeExecutables) +} + +fun parseToolOptions(options: String?): MutableList { + if (options == null) + return mutableListOf() + + val builder = StringBuilder() + val result = mutableListOf() + + var offset = 0 + + loop@ while (offset < options.length) { + val ch = options[offset] + if (Character.isWhitespace(ch)) { + if (builder.isNotEmpty()) { + result += builder.toString() + builder.clear() + } + + while (offset < options.length && Character.isWhitespace(options[offset])) { + offset++ + } + + continue@loop + } + + if (ch == '\'' || ch == '"') { + offset++ + + while (offset < options.length) { + val ch2 = options[offset++] + if (ch2 != ch) { + builder.append(ch2) + } else { + continue@loop + } + } + + throw GradleException("Unmatched quote in $options") + } + + builder.append(ch) + offset++ + } + + if (builder.isNotEmpty()) { + result += builder.toString() + } + + return result +} + +// For IntelliJ IDEA +tasks.withType { + if (name != "run") { + jvmArgs(addOpens.map { "--add-opens=$it=ALL-UNNAMED" }) +// if (javaVersion >= JavaVersion.VERSION_24) { +// jvmArgs("--enable-native-access=ALL-UNNAMED") +// } + } +} + +tasks.register("run") { + dependsOn(tasks.jar) + + group = "application" + + classpath = files(jarPath) + workingDir = rootProject.rootDir + + val vmOptions = parseToolOptions(System.getenv("HMCL_JAVA_OPTS")) + if (vmOptions.none { it.startsWith("-Dhmcl.offline.auth.restricted=") }) + vmOptions += "-Dhmcl.offline.auth.restricted=false" + + jvmArgs(vmOptions) + + val hmclJavaHome = System.getenv("HMCL_JAVA_HOME") + if (hmclJavaHome != null) { + this.executable( + file(hmclJavaHome).resolve("bin") + .resolve(if (System.getProperty("os.name").lowercase().startsWith("windows")) "java.exe" else "java") + ) + } + + doFirst { + logger.quiet("HMCL_JAVA_OPTS: {}", vmOptions) + logger.quiet("HMCL_JAVA_HOME: {}", hmclJavaHome ?: System.getProperty("java.home")) + } +} + +// Check Translations + +tasks.register("checkTranslations") { + val dir = layout.projectDirectory.dir("src/main/resources/assets/lang") + + englishFile.set(dir.file("I18N.properties")) + simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties")) + traditionalChineseFile.set(dir.file("I18N_zh.properties")) + classicalChineseFile.set(dir.file("I18N_lzh.properties")) +} + +// l10n + +val generatedDir = layout.buildDirectory.dir("generated") + +val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) { + inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties")) + outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") }) +} + +val createLanguageList by tasks.registering(CreateLanguageList::class) { + resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang")) + resourceBundleBaseName.set("I18N") + additionalLanguages.set(listOf("en-Qabs")) + outputFile.set(generatedDir.map { it.file("languages.json") }) +} + +val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) { + dependsOn(createLanguageList) + + languagesFile.set(createLanguageList.flatMap { it.outputFile }) + outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") }) +} + +// mcmod data + +tasks.register("parseModData") { + inputFile.set(layout.projectDirectory.file("mod.json")) + outputFile.set(layout.projectDirectory.file("src/main/resources/assets/mod_data.txt")) +} + +tasks.register("parseModPackData") { + inputFile.set(layout.projectDirectory.file("modpack.json")) + outputFile.set(layout.projectDirectory.file("src/main/resources/assets/modpack_data.txt")) +} diff --git a/HMCL/image/ShulkerSakura.jpg b/HMCL/image/ShulkerSakura.jpg new file mode 100644 index 0000000000..169abe1680 Binary files /dev/null and b/HMCL/image/ShulkerSakura.jpg differ diff --git a/HMCL/image/april_fools.png b/HMCL/image/april_fools.png new file mode 100644 index 0000000000..b248712fa8 Binary files /dev/null and b/HMCL/image/april_fools.png differ diff --git a/HMCL/image/bangbang93.jpg b/HMCL/image/bangbang93.jpg new file mode 100644 index 0000000000..64a801f9c5 Binary files /dev/null and b/HMCL/image/bangbang93.jpg differ diff --git a/HMCL/image/chest.png b/HMCL/image/chest.png new file mode 100644 index 0000000000..3bd2a7f008 Binary files /dev/null and b/HMCL/image/chest.png differ diff --git a/HMCL/image/chicken.png b/HMCL/image/chicken.png new file mode 100644 index 0000000000..558bb5f75e Binary files /dev/null and b/HMCL/image/chicken.png differ diff --git a/HMCL/image/cleanroom.png b/HMCL/image/cleanroom.png new file mode 100644 index 0000000000..39754085d0 Binary files /dev/null and b/HMCL/image/cleanroom.png differ diff --git a/HMCL/image/command.webp b/HMCL/image/command.webp new file mode 100644 index 0000000000..cc16b25a39 Binary files /dev/null and b/HMCL/image/command.webp differ diff --git a/HMCL/image/craft_table.webp b/HMCL/image/craft_table.webp new file mode 100644 index 0000000000..885ff6080e Binary files /dev/null and b/HMCL/image/craft_table.webp differ diff --git a/HMCL/image/discord.svg b/HMCL/image/discord.svg new file mode 100644 index 0000000000..c03e8e1270 --- /dev/null +++ b/HMCL/image/discord.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/HMCL/image/fabric.svg b/HMCL/image/fabric.svg new file mode 100644 index 0000000000..83501eba43 --- /dev/null +++ b/HMCL/image/fabric.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/HMCL/image/forge.png b/HMCL/image/forge.png new file mode 100644 index 0000000000..c79eec119f Binary files /dev/null and b/HMCL/image/forge.png differ diff --git a/HMCL/image/furnace.webp b/HMCL/image/furnace.webp new file mode 100644 index 0000000000..82a8061e2f Binary files /dev/null and b/HMCL/image/furnace.webp differ diff --git a/HMCL/image/gamerteam.jpg b/HMCL/image/gamerteam.jpg new file mode 100644 index 0000000000..0ee98e3ce7 Binary files /dev/null and b/HMCL/image/gamerteam.jpg differ diff --git a/HMCL/image/github.png b/HMCL/image/github.png new file mode 100644 index 0000000000..182a1a3f73 Binary files /dev/null and b/HMCL/image/github.png differ diff --git a/HMCL/image/grass.png b/HMCL/image/grass.png new file mode 100644 index 0000000000..2380963ab6 Binary files /dev/null and b/HMCL/image/grass.png differ diff --git a/HMCL/image/hmcl.png b/HMCL/image/hmcl.png new file mode 100644 index 0000000000..71b6d32f92 Binary files /dev/null and b/HMCL/image/hmcl.png differ diff --git a/HMCL/image/kookapp.png b/HMCL/image/kookapp.png new file mode 100644 index 0000000000..51fcdf5671 Binary files /dev/null and b/HMCL/image/kookapp.png differ diff --git a/HMCL/image/mcmod.png b/HMCL/image/mcmod.png new file mode 100644 index 0000000000..0b33c62aa5 Binary files /dev/null and b/HMCL/image/mcmod.png differ diff --git a/HMCL/image/optifine.png b/HMCL/image/optifine.png new file mode 100755 index 0000000000..b67f5d4e1b Binary files /dev/null and b/HMCL/image/optifine.png differ diff --git a/HMCL/image/quilt.png b/HMCL/image/quilt.png new file mode 100644 index 0000000000..591ad33658 Binary files /dev/null and b/HMCL/image/quilt.png differ diff --git a/HMCL/image/red_lnn.jpg b/HMCL/image/red_lnn.jpg new file mode 100644 index 0000000000..264cbf0429 Binary files /dev/null and b/HMCL/image/red_lnn.jpg differ diff --git a/HMCL/image/yellow_fish.jpg b/HMCL/image/yellow_fish.jpg new file mode 100644 index 0000000000..63037ce62c Binary files /dev/null and b/HMCL/image/yellow_fish.jpg differ diff --git a/HMCL/image/yushijinhun.jpg b/HMCL/image/yushijinhun.jpg new file mode 100644 index 0000000000..748b12bb84 Binary files /dev/null and b/HMCL/image/yushijinhun.jpg differ diff --git a/HMCL/settings.gradle b/HMCL/settings.gradle deleted file mode 100644 index 3b884e584d..0000000000 --- a/HMCL/settings.gradle +++ /dev/null @@ -1,2 +0,0 @@ -rootProject.name = 'HMCL' - diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java new file mode 100644 index 0000000000..396c880f76 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java @@ -0,0 +1,204 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl; + +import org.jackhuang.hmcl.util.FileSaver; +import org.jackhuang.hmcl.util.SelfDependencyPatcher; +import org.jackhuang.hmcl.util.SwingUtils; +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.util.platform.OperatingSystem; + +import java.io.IOException; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.nio.file.Files; +import java.util.concurrent.CancellationException; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public final class EntryPoint { + + private EntryPoint() { + } + + public static void main(String[] args) { + System.getProperties().putIfAbsent("java.net.useSystemProxies", "true"); + System.getProperties().putIfAbsent("javafx.autoproxy.disable", "true"); + System.getProperties().putIfAbsent("http.agent", "HMCL/" + Metadata.VERSION); + + createHMCLDirectories(); + LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs")); + + if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) + System.getProperties().putIfAbsent("prism.forceGPU", "true"); + + String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE"); + if (animationFrameRate != null) { + try { + if (Integer.parseInt(animationFrameRate) <= 0) + throw new NumberFormatException(animationFrameRate); + + System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate); + } catch (NumberFormatException e) { + LOG.warning("Invalid animation frame rate: " + animationFrameRate); + } + } + + checkDirectoryPath(); + + if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) + initIcon(); + + checkJavaFX(); + verifyJavaFX(); + addEnableNativeAccess(); + enableUnsafeMemoryAccess(); + + Launcher.main(args); + } + + public static void exit(int exitCode) { + FileSaver.shutdown(); + LOG.shutdown(); + System.exit(exitCode); + } + + private static void createHMCLDirectories() { + if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) { + try { + Files.createDirectories(Metadata.HMCL_CURRENT_DIRECTORY); + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { + try { + Files.setAttribute(Metadata.HMCL_CURRENT_DIRECTORY, "dos:hidden", true); + } catch (IOException e) { + LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_CURRENT_DIRECTORY, e); + } + } + } catch (IOException e) { + // Logger has not been started yet, so print directly to System.err + System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_CURRENT_DIRECTORY); + e.printStackTrace(System.err); + showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_CURRENT_DIRECTORY)); + } + } + + if (!Files.isDirectory(Metadata.HMCL_GLOBAL_DIRECTORY)) { + try { + Files.createDirectories(Metadata.HMCL_GLOBAL_DIRECTORY); + } catch (IOException e) { + LOG.warning("Failed to create HMCL global directory " + Metadata.HMCL_GLOBAL_DIRECTORY, e); + } + } + } + + private static void initIcon() { + try { + if (java.awt.Taskbar.isTaskbarSupported()) { + var image = java.awt.Toolkit.getDefaultToolkit().getImage(EntryPoint.class.getResource("/assets/img/icon-mac.png")); + java.awt.Taskbar.getTaskbar().setIconImage(image); + } + } catch (Throwable e) { + LOG.warning("Failed to set application icon", e); + } + } + + private static void checkDirectoryPath() { + String currentDir = System.getProperty("user.dir", ""); + if (currentDir.contains("!")) { + LOG.error("The current working path contains an exclamation mark: " + currentDir); + // No Chinese translation because both Swing and JavaFX cannot render Chinese character properly when exclamation mark exists in the path. + showErrorAndExit("Exclamation mark(!) is not allowed in the path where HMCL is in.\n" + + "The path is " + currentDir); + } + } + + private static void checkJavaFX() { + try { + SelfDependencyPatcher.patch(); + } catch (SelfDependencyPatcher.PatchException e) { + LOG.error("Unable to patch JVM", e); + showErrorAndExit(i18n("fatal.javafx.missing")); + } catch (SelfDependencyPatcher.IncompatibleVersionException e) { + LOG.error("Unable to patch JVM", e); + showErrorAndExit(i18n("fatal.javafx.incompatible")); + } catch (CancellationException e) { + LOG.error("User cancels downloading JavaFX", e); + exit(0); + } + } + + /** + * Check if JavaFX exists but is incomplete + */ + private static void verifyJavaFX() { + try { + Class.forName("javafx.beans.binding.Binding"); // javafx.base + Class.forName("javafx.stage.Stage"); // javafx.graphics + Class.forName("javafx.scene.control.Skin"); // javafx.controls + } catch (Exception e) { + LOG.warning("JavaFX is incomplete or not found", e); + showErrorAndExit(i18n("fatal.javafx.incomplete")); + } + } + + private static void addEnableNativeAccess() { + if (JavaRuntime.CURRENT_VERSION > 21) { + try { + // javafx.graphics + Module module = Class.forName("javafx.stage.Stage").getModule(); + if (module.isNamed()) { + try { + MethodHandles.Lookup lookup = MethodHandles.privateLookupIn(Module.class, MethodHandles.lookup()); + MethodHandle implAddEnableNativeAccess = lookup.findVirtual(Module.class, + "implAddEnableNativeAccess", MethodType.methodType(Module.class)); + Module ignored = (Module) implAddEnableNativeAccess.invokeExact(module); + } catch (Throwable e) { + e.printStackTrace(System.err); + } + } + } catch (ClassNotFoundException e) { + LOG.error("Failed to add enable native access for JavaFX", e); + showErrorAndExit(i18n("fatal.javafx.incomplete")); + } + } + } + + private static void enableUnsafeMemoryAccess() { + // https://openjdk.org/jeps/498 + if (JavaRuntime.CURRENT_VERSION == 24 || JavaRuntime.CURRENT_VERSION == 25) { + try { + Class clazz = Class.forName("sun.misc.Unsafe"); + boolean ignored = (boolean) MethodHandles.privateLookupIn(clazz, MethodHandles.lookup()) + .findStatic(clazz, "trySetMemoryAccessWarned", MethodType.methodType(boolean.class)) + .invokeExact(); + } catch (Throwable e) { + LOG.warning("Failed to enable unsafe memory access", e); + } + } + } + + /** + * Indicates that a fatal error has occurred, and that the application cannot start. + */ + private static void showErrorAndExit(String message) { + SwingUtils.showErrorDialog(message); + exit(1); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java index e342d40374..a558c4b748 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,42 +17,102 @@ */ package org.jackhuang.hmcl; -import com.jfoenix.concurrency.JFXUtilities; import javafx.application.Application; import javafx.application.Platform; +import javafx.beans.value.ObservableBooleanValue; +import javafx.scene.control.Alert; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.ButtonType; +import javafx.scene.input.Clipboard; +import javafx.scene.input.DataFormat; import javafx.stage.Stage; import org.jackhuang.hmcl.setting.ConfigHolder; +import org.jackhuang.hmcl.setting.SambaException; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.FileSaver; +import org.jackhuang.hmcl.task.AsyncTaskExecutor; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.upgrade.UpdateChecker; -import org.jackhuang.hmcl.util.*; +import org.jackhuang.hmcl.upgrade.UpdateHandler; +import org.jackhuang.hmcl.util.CrashReporter; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.platform.Architecture; +import org.jackhuang.hmcl.util.platform.CommandBuilder; +import org.jackhuang.hmcl.util.platform.NativeUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.platform.SystemInfo; import java.io.File; import java.io.IOException; import java.lang.management.ManagementFactory; -import java.net.URISyntaxException; -import java.net.URL; -import java.net.URLClassLoader; +import java.lang.management.MemoryPoolMXBean; +import java.net.CookieHandler; +import java.net.CookieManager; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; -import java.util.LinkedList; -import java.util.List; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Objects; +import java.util.Optional; import java.util.concurrent.TimeUnit; -import static org.jackhuang.hmcl.util.Logging.LOG; +import static org.jackhuang.hmcl.ui.FXUtils.runInFX; +import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class Launcher extends Application { + public static final CookieManager COOKIE_MANAGER = new CookieManager(); @Override public void start(Stage primaryStage) { Thread.currentThread().setUncaughtExceptionHandler(CRASH_REPORTER); + CookieHandler.setDefault(COOKIE_MANAGER); + + LOG.info("JavaFX Version: " + System.getProperty("javafx.runtime.version")); + LOG.info("Prism Pipeline: " + FXUtils.GRAPHICS_PIPELINE); + LOG.info("Dark Mode: " + Optional.ofNullable(FXUtils.DARK_MODE).map(ObservableBooleanValue::get).orElse(false)); + LOG.info("Reduced Motion: " + Objects.requireNonNullElse(FXUtils.REDUCED_MOTION, false)); + try { try { ConfigHolder.init(); + } catch (SambaException e) { + showAlert(AlertType.WARNING, i18n("fatal.samba")); } catch (IOException e) { - Main.showErrorAndExit(i18n("fatal.config_loading_failure", Paths.get("").toAbsolutePath().normalize())); + LOG.error("Failed to load config", e); + checkConfigInTempDir(); + checkConfigOwner(); + showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent())); + EntryPoint.exit(1); + } + + // https://lapcatsoftware.com/articles/app-translocation.html + if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS + && ConfigHolder.isNewlyCreated() + && System.getProperty("user.dir").startsWith("/private/var/folders/")) { + if (showAlert(AlertType.WARNING, i18n("fatal.mac_app_translocation"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) + return; + } else { + checkConfigInTempDir(); + } + + if (ConfigHolder.isOwnerChanged()) { + if (showAlert(AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) + return; + } + + if (ConfigHolder.isUnsupportedVersion()) { + showAlert(AlertType.WARNING, i18n("fatal.config_unsupported_version")); + } + + if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) { + showAlert(AlertType.WARNING, i18n("fatal.illegal_char")); } // runLater to ensure ConfigHolder.init() finished initialization @@ -61,8 +121,6 @@ public void start(Stage primaryStage) { // Stage.show() cannot work again because JavaFX Toolkit have already shut down. Platform.setImplicitExit(false); Controllers.initialize(primaryStage); - primaryStage.setResizable(false); - primaryStage.setScene(Controllers.getScene()); UpdateChecker.init(); @@ -73,22 +131,152 @@ public void start(Stage primaryStage) { } } + private static ButtonType showAlert(AlertType alertType, String contentText, ButtonType... buttons) { + return new Alert(alertType, contentText, buttons).showAndWait().orElse(null); + } + + private static boolean isConfigInTempDir() { + String configPath = ConfigHolder.configLocation().toString(); + + String tmpdir = System.getProperty("java.io.tmpdir"); + if (StringUtils.isNotBlank(tmpdir) && configPath.startsWith(tmpdir)) + return true; + + String[] tempFolderNames = {"Temp", "Cache", "Caches"}; + for (String name : tempFolderNames) { + if (configPath.contains(File.separator + name + File.separator)) + return true; + } + + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { + return configPath.contains("\\Temporary Internet Files\\") + || configPath.contains("\\INetCache\\") + || configPath.contains("\\$Recycle.Bin\\") + || configPath.contains("\\recycler\\"); + } else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) { + return configPath.startsWith("/tmp/") + || configPath.startsWith("/var/tmp/") + || configPath.startsWith("/var/cache/") + || configPath.startsWith("/dev/shm/") + || configPath.contains("/Trash/"); + } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { + return configPath.startsWith("/var/folders/") + || configPath.startsWith("/private/var/folders/") + || configPath.startsWith("/tmp/") + || configPath.startsWith("/private/tmp/") + || configPath.startsWith("/var/tmp/") + || configPath.startsWith("/private/var/tmp/") + || configPath.contains("/.Trash/"); + } else { + return false; + } + } + + private static void checkConfigInTempDir() { + if (ConfigHolder.isNewlyCreated() && isConfigInTempDir() + && showAlert(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) { + EntryPoint.exit(0); + } + } + + private static void checkConfigOwner() { + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) + return; + + String userName = System.getProperty("user.name"); + String owner; + try { + owner = Files.getOwner(ConfigHolder.configLocation()).getName(); + } catch (IOException ioe) { + LOG.warning("Failed to get file owner", ioe); + return; + } + + if (Files.isWritable(ConfigHolder.configLocation()) || userName.equals("root") || userName.equals(owner)) + return; + + ArrayList files = new ArrayList<>(); + files.add(ConfigHolder.configLocation().toString()); + if (Files.exists(Metadata.HMCL_GLOBAL_DIRECTORY)) + files.add(Metadata.HMCL_GLOBAL_DIRECTORY.toString()); + if (Files.exists(Metadata.HMCL_CURRENT_DIRECTORY)) + files.add(Metadata.HMCL_CURRENT_DIRECTORY.toString()); + + Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize(); + if (Files.exists(mcDir)) + files.add(mcDir.toString()); + + String command = new CommandBuilder().add("sudo", "chown", "-R", userName).addAll(files).toString(); + ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit")); + + if (showAlert(AlertType.ERROR, + i18n("fatal.config_loading_failure.unix", owner, command), + copyAndExit, ButtonType.CLOSE) == copyAndExit) { + Clipboard.getSystemClipboard() + .setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command)); + } + EntryPoint.exit(1); + } + + @Override + public void stop() throws Exception { + Controllers.onApplicationStop(); + FileSaver.shutdown(); + LOG.shutdown(); + } + public static void main(String[] args) { + if (UpdateHandler.processArguments(args)) { + LOG.shutdown(); + return; + } + Thread.setDefaultUncaughtExceptionHandler(CRASH_REPORTER); + AsyncTaskExecutor.setUncaughtExceptionHandler(new CrashReporter(false)); try { LOG.info("*** " + Metadata.TITLE + " ***"); - LOG.info("Operating System: " + System.getProperty("os.name") + ' ' + OperatingSystem.SYSTEM_VERSION); + LOG.info("Operating System: " + (OperatingSystem.OS_RELEASE_PRETTY_NAME == null + ? OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION.getVersion() + : OperatingSystem.OS_RELEASE_PRETTY_NAME + " (" + OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION.getVersion() + ')')); + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { + LOG.info("Processor Identifier: " + System.getenv("PROCESSOR_IDENTIFIER")); + } + LOG.info("System Architecture: " + Architecture.SYSTEM_ARCH.getDisplayName()); + LOG.info("Native Encoding: " + OperatingSystem.NATIVE_CHARSET); + LOG.info("JNU Encoding: " + System.getProperty("sun.jnu.encoding")); + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { + LOG.info("Code Page: " + OperatingSystem.CODE_PAGE); + } + LOG.info("Java Architecture: " + Architecture.CURRENT_ARCH.getDisplayName()); LOG.info("Java Version: " + System.getProperty("java.version") + ", " + System.getProperty("java.vendor")); LOG.info("Java VM Version: " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.info") + "), " + System.getProperty("java.vm.vendor")); LOG.info("Java Home: " + System.getProperty("java.home")); - LOG.info("Current Directory: " + Paths.get("").toAbsolutePath()); - LOG.info("HMCL Directory: " + Metadata.HMCL_DIRECTORY); - LOG.info("Memory: " + Runtime.getRuntime().maxMemory() / 1024 / 1024 + "MB"); - ManagementFactory.getMemoryPoolMXBeans().stream().filter(bean -> bean.getName().equals("Metaspace")).findAny() - .ifPresent(bean -> LOG.info("Metaspace: " + bean.getUsage().getUsed() / 1024 / 1024 + "MB")); + LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY); + LOG.info("HMCL Global Directory: " + Metadata.HMCL_GLOBAL_DIRECTORY); + LOG.info("HMCL Current Directory: " + Metadata.HMCL_CURRENT_DIRECTORY); + LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found")); + LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory")); + LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory())); + try { + for (MemoryPoolMXBean bean : ManagementFactory.getMemoryPoolMXBeans()) { + if ("Metaspace".equals(bean.getName())) { + long bytes = bean.getUsage().getUsed(); + LOG.info("Metaspace: " + MEGABYTES.formatBytes(bytes)); + break; + } + } + } catch (NoClassDefFoundError ignored) { + } + LOG.info("Native Backend: " + (NativeUtils.USE_JNA ? "JNA" : "None")); + if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) { + LOG.info("XDG Session Type: " + System.getenv("XDG_SESSION_TYPE")); + LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP")); + } + + Lang.thread(SystemInfo::initialize, "Detection System Information", true); - launch(args); + launch(Launcher.class, args); } catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter. CRASH_REPORTER.uncaughtException(Thread.currentThread(), e); } @@ -97,48 +285,28 @@ public static void main(String[] args) { public static void stopApplication() { LOG.info("Stopping application.\n" + StringUtils.getStackTrace(Thread.currentThread().getStackTrace())); - JFXUtilities.runInFX(() -> { + runInFX(() -> { if (Controllers.getStage() == null) return; Controllers.getStage().close(); Schedulers.shutdown(); Controllers.shutdown(); Platform.exit(); - Lang.executeDelayed(OperatingSystem::forceGC, TimeUnit.SECONDS, 5, true); }); } public static void stopWithoutPlatform() { LOG.info("Stopping application without JavaFX Toolkit.\n" + StringUtils.getStackTrace(Thread.currentThread().getStackTrace())); - JFXUtilities.runInFX(() -> { + runInFX(() -> { if (Controllers.getStage() == null) return; Controllers.getStage().close(); Schedulers.shutdown(); Controllers.shutdown(); - Lang.executeDelayed(OperatingSystem::forceGC, TimeUnit.SECONDS, 5, true); + Lang.executeDelayed(System::gc, TimeUnit.SECONDS, 5, true); }); } - public static List getCurrentJarFiles() { - List result = new LinkedList<>(); - if (Launcher.class.getClassLoader() instanceof URLClassLoader) { - URL[] urls = ((URLClassLoader) Launcher.class.getClassLoader()).getURLs(); - for (URL u : urls) - try { - File f = new File(u.toURI()); - if (f.isFile() && (f.getName().endsWith(".exe") || f.getName().endsWith(".jar"))) - result.add(f); - } catch (URISyntaxException e) { - return null; - } - } - if (result.isEmpty()) - return null; - else - return result; - } - - public static final CrashReporter CRASH_REPORTER = new CrashReporter(); + public static final CrashReporter CRASH_REPORTER = new CrashReporter(true); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Main.java b/HMCL/src/main/java/org/jackhuang/hmcl/Main.java deleted file mode 100644 index ee6c387d5f..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Main.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl; - -import org.jackhuang.hmcl.upgrade.UpdateHandler; -import org.jackhuang.hmcl.util.Logging; - -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; -import javax.swing.*; -import java.io.File; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.X509Certificate; -import java.util.logging.Level; - -import static org.jackhuang.hmcl.util.Lang.thread; -import static org.jackhuang.hmcl.util.Logging.LOG; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -public final class Main { - - public static void main(String[] args) { - System.setProperty("java.net.useSystemProxies", "true"); - System.setProperty("http.agent", "HMCL/" + Metadata.VERSION); - System.setProperty("javafx.autoproxy.disable", "true"); - - checkJavaFX(); - checkDirectoryPath(); - - // This environment check will take ~300ms - thread(() -> checkDSTRootCAX3(), "CA Certificate Check", true); - - Logging.start(Metadata.HMCL_DIRECTORY.resolve("logs")); - - if (UpdateHandler.processArguments(args)) { - return; - } - - Launcher.main(args); - } - - private static void checkDirectoryPath() { - String currentDirectory = new File("").getAbsolutePath(); - if (currentDirectory.contains("!")) { - // No Chinese translation because both Swing and JavaFX cannot render Chinese character properly when exclamation mark exists in the path. - showErrorAndExit("Exclamation mark(!) is not allowed in the path where HMCL is in.\n" - + "The path is " + currentDirectory); - } - } - - private static void checkJavaFX() { - try { - Class.forName("javafx.application.Application"); - } catch (ClassNotFoundException e) { - showErrorAndExit(i18n("fatal.missing_javafx")); - } - } - - private static void checkDSTRootCAX3() { - TrustManagerFactory tmf; - try { - tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmf.init((KeyStore) null); - } catch (NoSuchAlgorithmException | KeyStoreException e) { - LOG.log(Level.WARNING, "Failed to init TrustManagerFactory", e); - // don't know what to do here - return; - } - for (TrustManager tm : tmf.getTrustManagers()) { - if (tm instanceof X509TrustManager) { - for (X509Certificate cert : ((X509TrustManager) tm).getAcceptedIssuers()) { - if ("CN=DST Root CA X3, O=Digital Signature Trust Co.".equals((cert.getSubjectDN().getName()))) { - return; - } - } - } - } - showWarningAndContinue(i18n("fatal.missing_dst_root_ca_x3")); - } - - /** - * Indicates that a fatal error has occurred, and that the application cannot start. - */ - static void showErrorAndExit(String message) { - System.err.println(message); - System.err.println("A fatal error has occurred, forcibly exiting."); - JOptionPane.showMessageDialog(null, message, "Error", JOptionPane.ERROR_MESSAGE); - System.exit(1); - } - - /** - * Indicates that potential issues have been detected, and that the application may not function properly (but it can still run). - */ - static void showWarningAndContinue(String message) { - System.err.println(message); - System.err.println("Potential issues have been detected."); - JOptionPane.showMessageDialog(null, message, "Warning", JOptionPane.WARNING_MESSAGE); - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java index 6a4f03bae0..7d54402dfe 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,26 +17,114 @@ */ package org.jackhuang.hmcl; -import java.nio.file.Path; - +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.EnumSet; /** * Stores metadata about this application. */ public final class Metadata { - private Metadata() {} + private Metadata() { + } - public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.thisJar().flatMap(JarUtils::getImplementationVersion).orElse("@develop@")); public static final String NAME = "HMCL"; + public static final String FULL_NAME = "Hello Minecraft! Launcher"; + public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@")); + public static final String TITLE = NAME + " " + VERSION; - - public static final String UPDATE_URL = System.getProperty("hmcl.update_source.override", "https://hmcl.huangyuhui.net/api/update_link"); - public static final String CONTACT_URL = "https://hmcl.huangyuhui.net/contact"; - public static final String HELP_URL = "https://hmcl.huangyuhui.net/help"; - public static final String PUBLISH_URL = "http://www.mcbbs.net/thread-142335-1-1.html"; + public static final String FULL_TITLE = FULL_NAME + " v" + VERSION; + + public static final int MINIMUM_REQUIRED_JAVA_VERSION = 17; + public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 17; + public static final int RECOMMENDED_JAVA_VERSION = 21; + + public static final String PUBLISH_URL = "https://hmcl.huangyuhui.net"; + public static final String ABOUT_URL = PUBLISH_URL + "/about"; + public static final String DOWNLOAD_URL = PUBLISH_URL + "/download"; + public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link"); + + public static final String DOCS_URL = "https://docs.hmcl.net"; + public static final String CONTACT_URL = DOCS_URL + "/help.html"; + public static final String CHANGELOG_URL = DOCS_URL + "/changelog/"; + public static final String EULA_URL = DOCS_URL + "/eula/hmcl.html"; + public static final String GROUPS_URL = "https://www.bilibili.com/opus/905435541874409529"; + public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly"); + public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null); + + public static final Path CURRENT_DIRECTORY = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize(); public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft"); - public static final Path HMCL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl"); + public static final Path HMCL_GLOBAL_DIRECTORY; + public static final Path HMCL_CURRENT_DIRECTORY; + public static final Path DEPENDENCIES_DIRECTORY; + + static { + String hmclHome = System.getProperty("hmcl.home"); + if (hmclHome == null) { + if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) { + String xdgData = System.getenv("XDG_DATA_HOME"); + if (StringUtils.isNotBlank(xdgData)) { + HMCL_GLOBAL_DIRECTORY = Paths.get(xdgData, "hmcl").toAbsolutePath().normalize(); + } else { + HMCL_GLOBAL_DIRECTORY = Paths.get(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize(); + } + } else { + HMCL_GLOBAL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl"); + } + } else { + HMCL_GLOBAL_DIRECTORY = Paths.get(hmclHome).toAbsolutePath().normalize(); + } + + String hmclCurrentDir = System.getProperty("hmcl.dir"); + HMCL_CURRENT_DIRECTORY = hmclCurrentDir != null + ? Paths.get(hmclCurrentDir).toAbsolutePath().normalize() + : CURRENT_DIRECTORY.resolve(".hmcl"); + DEPENDENCIES_DIRECTORY = HMCL_CURRENT_DIRECTORY.resolve("dependencies"); + } + + public static boolean isStable() { + return "stable".equals(BUILD_CHANNEL); + } + + public static boolean isDev() { + return "dev".equals(BUILD_CHANNEL); + } + + public static boolean isNightly() { + return !isStable() && !isDev(); + } + + public static @Nullable String getSuggestedJavaDownloadLink() { + if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64_OW) + return "https://www.loongnix.cn/zh/api/java/downloads-jdk21/index.html"; + else { + EnumSet supportedArchitectures; + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) + supportedArchitectures = EnumSet.of(Architecture.X86_64, Architecture.X86, Architecture.ARM64); + else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX) + supportedArchitectures = EnumSet.of( + Architecture.X86_64, Architecture.X86, + Architecture.ARM64, Architecture.ARM32, + Architecture.RISCV64, Architecture.LOONGARCH64 + ); + else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) + supportedArchitectures = EnumSet.of(Architecture.X86_64, Architecture.ARM64); + else + supportedArchitectures = EnumSet.noneOf(Architecture.class); + if (supportedArchitectures.contains(Architecture.SYSTEM_ARCH)) + return String.format("https://docs.hmcl.net/downloads/%s/%s.html", + OperatingSystem.CURRENT_OS.getCheckedName(), + Architecture.SYSTEM_ARCH.getCheckedName() + ); + else + return null; + } + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/countly/CrashReport.java b/HMCL/src/main/java/org/jackhuang/hmcl/countly/CrashReport.java new file mode 100644 index 0000000000..26b4257f2d --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/countly/CrashReport.java @@ -0,0 +1,54 @@ +package org.jackhuang.hmcl.countly; + +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.platform.Architecture; +import org.jackhuang.hmcl.util.platform.OperatingSystem; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class CrashReport { + + private final Thread thread; + private final Throwable throwable; + private final String stackTrace; + + public CrashReport(Thread thread, Throwable throwable) { + this.thread = thread; + this.throwable = throwable; + stackTrace = StringUtils.getStackTrace(throwable); + } + + public Throwable getThrowable() { + return this.throwable; + } + + public boolean shouldBeReport() { + if (!stackTrace.contains("org.jackhuang")) + return false; + + if (throwable instanceof VirtualMachineError) + return false; + + return true; + } + + public String getDisplayText() { + return "---- Hello Minecraft! Crash Report ----\n" + + " Version: " + Metadata.VERSION + "\n" + + " Time: " + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format(LocalDateTime.now()) + "\n" + + " Thread: " + thread + "\n" + + "\n Content: \n " + + stackTrace + "\n\n" + + "-- System Details --\n" + + " Operating System: " + OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION.getVersion() + "\n" + + " System Architecture: " + Architecture.SYSTEM_ARCH.getDisplayName() + "\n" + + " Java Architecture: " + Architecture.CURRENT_ARCH.getDisplayName() + "\n" + + " Java Version: " + System.getProperty("java.version") + ", " + System.getProperty("java.vendor") + "\n" + + " Java VM Version: " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.info") + "), " + System.getProperty("java.vm.vendor") + "\n" + + " JVM Max Memory: " + Runtime.getRuntime().maxMemory() + "\n" + + " JVM Total Memory: " + Runtime.getRuntime().totalMemory() + "\n" + + " JVM Free Memory: " + Runtime.getRuntime().freeMemory() + "\n"; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLCacheRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLCacheRepository.java index 85ff1af823..46fa26394b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLCacheRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLCacheRepository.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index fd7a0d5e82..e19fc0721d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,24 +21,34 @@ import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.launch.DefaultLauncher; import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.util.i18n.LocaleUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; -import java.util.Map; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.stream.Stream; + +import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author huangyuhui */ public final class HMCLGameLauncher extends DefaultLauncher { - public HMCLGameLauncher(GameRepository repository, String versionId, AuthInfo authInfo, LaunchOptions options) { - this(repository, versionId, authInfo, options, null); + public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options) { + this(repository, version, authInfo, options, null); } - public HMCLGameLauncher(GameRepository repository, String versionId, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { - this(repository, versionId, authInfo, options, listener, true); + public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { + this(repository, version, authInfo, options, listener, true); } - public HMCLGameLauncher(GameRepository repository, String versionId, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { - super(repository, versionId, authInfo, options, listener, daemon); + public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { + super(repository, version, authInfo, options, listener, daemon); } @Override @@ -48,4 +58,95 @@ protected Map getConfigurations() { res.put("${launcher_version}", Metadata.VERSION); return res; } + + private void generateOptionsTxt() { + if (config().isDisableAutoGameOptions()) + return; + + Path runDir = repository.getRunDirectory(version.getId()); + Path optionsFile = runDir.resolve("options.txt"); + Path configFolder = runDir.resolve("config"); + + if (Files.exists(optionsFile)) + return; + + if (Files.isDirectory(configFolder)) { + try (Stream stream = Files.walk(configFolder, 2, FileVisitOption.FOLLOW_LINKS)) { + if (stream.anyMatch(file -> "options.txt".equals(FileUtils.getName(file)))) + return; + } catch (IOException e) { + LOG.warning("Failed to visit config folder", e); + } + } + + Locale locale = Locale.getDefault(); + + /* + * 1.0 : No language option, do not set for these versions + * 1.1 ~ 1.5 : zh_CN works fine, zh_cn will crash (the last two letters must be uppercase, otherwise it will cause an NPE crash) + * 1.6 ~ 1.10 : zh_CN works fine, zh_cn will automatically switch to English + * 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected + * 1.13+ : zh_cn works fine, zh_CN will automatically switch to English + */ + GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version)); + if (gameVersion.compareTo("1.1") < 0) + return; + + String lang = normalizedLanguageTag(locale, gameVersion); + if (lang.isEmpty()) + return; + + if (gameVersion.compareTo("1.11") >= 0) + lang = lang.toLowerCase(Locale.ROOT); + + try { + Files.createDirectories(optionsFile.getParent()); + Files.writeString(optionsFile, String.format("lang:%s\n", lang)); + } catch (IOException e) { + LOG.warning("Unable to generate options.txt", e); + } + } + + private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) { + String region = locale.getCountry(); + + return switch (LocaleUtils.getRootLanguage(locale)) { + case "es" -> "es_ES"; + case "ja" -> "ja_JP"; + case "ru" -> "ru_RU"; + case "uk" -> "uk_UA"; + case "zh" -> { + if ("lzh".equals(locale.getLanguage()) && gameVersion.compareTo("1.16") >= 0) + yield "lzh"; + + String script = LocaleUtils.getScript(locale); + if ("Hant".equals(script)) { + if ((region.equals("HK") || region.equals("MO") && gameVersion.compareTo("1.16") >= 0)) + yield "zh_HK"; + yield "zh_TW"; + } + yield "zh_CN"; + } + case "en" -> { + if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) { + yield "en_UD"; + } + + yield ""; + } + default -> ""; + }; + } + + @Override + public ManagedProcess launch() throws IOException, InterruptedException { + generateOptionsTxt(); + return super.launch(); + } + + @Override + public void makeLaunchScript(Path scriptFile) throws IOException { + generateOptionsTxt(); + super.makeLaunchScript(scriptFile); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java index 4c0146bcf7..5a095fb102 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,29 +19,54 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; import javafx.scene.image.Image; -import org.jackhuang.hmcl.event.EventBus; -import org.jackhuang.hmcl.event.RefreshedVersionsEvent; -import org.jackhuang.hmcl.event.RefreshingVersionsEvent; -import org.jackhuang.hmcl.setting.EnumGameDirectory; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.event.Event; +import org.jackhuang.hmcl.event.EventManager; +import org.jackhuang.hmcl.mod.ModAdviser; +import org.jackhuang.hmcl.mod.Modpack; +import org.jackhuang.hmcl.mod.ModpackConfiguration; +import org.jackhuang.hmcl.mod.ModpackProvider; import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.util.FileSaver; +import org.jackhuang.hmcl.setting.VersionIconType; import org.jackhuang.hmcl.setting.VersionSetting; -import org.jackhuang.hmcl.util.Logging; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.platform.SystemInfo; +import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jetbrains.annotations.Nullable; -import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.time.Instant; import java.util.*; -import java.util.logging.Level; +import java.util.stream.Collectors; +import java.util.stream.Stream; -public class HMCLGameRepository extends DefaultGameRepository { +import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import static org.jackhuang.hmcl.util.Pair.pair; + +public final class HMCLGameRepository extends DefaultGameRepository { private final Profile profile; - private final Map versionSettings = new HashMap<>(); + + // local version settings + private final Map localVersionSettings = new HashMap<>(); private final Set beingModpackVersions = new HashSet<>(); - public boolean checkedModpack = false, checkingModpack = false; + public final EventManager onVersionIconChanged = new EventManager<>(); - public HMCLGameRepository(Profile profile, File baseDirectory) { + public HMCLGameRepository(Profile profile, Path baseDirectory) { super(baseDirectory); this.profile = profile; } @@ -51,50 +76,69 @@ public Profile getProfile() { } @Override - public File getRunDirectory(String id) { - if (beingModpackVersions.contains(id) || isModpack(id)) - return getVersionRoot(id); - else { - VersionSetting vs = profile.getVersionSetting(id); - switch (vs.getGameDirType()) { - case VERSION_FOLDER: return getVersionRoot(id); - case ROOT_FOLDER: return super.getRunDirectory(id); - case CUSTOM: return new File(vs.getGameDir()); - default: throw new Error(); - } + public GameDirectoryType getGameDirectoryType(String id) { + if (beingModpackVersions.contains(id) || isModpack(id)) { + return GameDirectoryType.VERSION_FOLDER; + } else { + return getVersionSetting(id).getGameDirType(); + } + } + + @Override + public Path getRunDirectory(String id) { + switch (getGameDirectoryType(id)) { + case VERSION_FOLDER: + return getVersionRoot(id); + case ROOT_FOLDER: + return super.getRunDirectory(id); + case CUSTOM: + try { + return Path.of(getVersionSetting(id).getGameDir()); + } catch (InvalidPathException ignored) { + return getVersionRoot(id); + } + default: + throw new AssertionError("Unreachable"); } } + public Stream getDisplayVersions() { + return getVersions().stream() + .filter(v -> !v.isHidden()) + .sorted(Comparator.comparing((Version v) -> Lang.requireNonNullElse(v.getReleaseTime(), Instant.EPOCH)) + .thenComparing(v -> VersionNumber.asVersion(v.getId()))); + } + @Override protected void refreshVersionsImpl() { - versionSettings.clear(); + localVersionSettings.clear(); super.refreshVersionsImpl(); - versions.keySet().forEach(this::loadVersionSetting); + versions.keySet().forEach(this::loadLocalVersionSetting); + versions.keySet().forEach(version -> { + if (isModpack(version)) { + specializeVersionSetting(version); + } + }); try { - File file = new File(getBaseDirectory(), "launcher_profiles.json"); - if (!file.exists() && !versions.isEmpty()) - FileUtils.writeText(file, PROFILE); + Path file = getBaseDirectory().resolve("launcher_profiles.json"); + if (!Files.exists(file) && !versions.isEmpty()) { + Files.createDirectories(file.getParent()); + Files.writeString(file, PROFILE); + } } catch (IOException ex) { - Logging.LOG.log(Level.WARNING, "Unable to create launcher_profiles.json, Forge/LiteLoader installer will not work.", ex); + LOG.warning("Unable to create launcher_profiles.json, Forge/LiteLoader installer will not work.", ex); } } - @Override - public void refreshVersions() { - EventBus.EVENT_BUS.fireEvent(new RefreshingVersionsEvent(this)); - refreshVersionsImpl(); - EventBus.EVENT_BUS.fireEvent(new RefreshedVersionsEvent(this)); - } - - public void changeDirectory(File newDirectory) { + public void changeDirectory(Path newDirectory) { setBaseDirectory(newDirectory); refreshVersionsAsync().start(); } - private void clean(File directory) throws IOException { - FileUtils.deleteDirectory(new File(directory, "crash-reports")); - FileUtils.deleteDirectory(new File(directory, "logs")); + private void clean(Path directory) throws IOException { + FileUtils.deleteDirectory(directory.resolve("crash-reports")); + FileUtils.deleteDirectory(directory.resolve("logs")); } public void clean(String id) throws IOException { @@ -102,39 +146,83 @@ public void clean(String id) throws IOException { clean(getRunDirectory(id)); } - private File getVersionSettingFile(String id) { - return new File(getVersionRoot(id), "hmclversion.cfg"); + public void duplicateVersion(String srcId, String dstId, boolean copySaves) throws IOException { + Path srcDir = getVersionRoot(srcId); + Path dstDir = getVersionRoot(dstId); + + Version fromVersion = getVersion(srcId); + + List blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST); + blackList.add(srcId + ".jar"); + blackList.add(srcId + ".json"); + if (!copySaves) + blackList.add("saves"); + + if (Files.exists(dstDir)) throw new IOException("Version exists"); + + Files.createDirectories(dstDir); + FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null)); + + Path fromJson = srcDir.resolve(srcId + ".json"); + Path fromJar = srcDir.resolve(srcId + ".jar"); + Path toJson = dstDir.resolve(dstId + ".json"); + Path toJar = dstDir.resolve(dstId + ".jar"); + + if (Files.exists(fromJar)) { + Files.copy(fromJar, toJar); + } + Files.copy(fromJson, toJson); + + JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId)); + + VersionSetting oldVersionSetting = getVersionSetting(srcId).clone(); + GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType(); + oldVersionSetting.setUsesGlobal(false); + oldVersionSetting.setGameDirType(GameDirectoryType.VERSION_FOLDER); + VersionSetting newVersionSetting = initLocalVersionSetting(dstId, oldVersionSetting); + saveVersionSetting(dstId); + + Path srcGameDir = getRunDirectory(srcId); + Path dstGameDir = getRunDirectory(dstId); + + if (originalGameDirType != GameDirectoryType.VERSION_FOLDER) + FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null)); + } + + private Path getLocalVersionSettingFile(String id) { + return getVersionRoot(id).resolve("hmclversion.cfg"); } - private void loadVersionSetting(String id) { - File file = getVersionSettingFile(id); - if (file.exists()) + private void loadLocalVersionSetting(String id) { + Path file = getLocalVersionSettingFile(id); + if (Files.exists(file)) try { - VersionSetting versionSetting = GSON.fromJson(FileUtils.readText(file), VersionSetting.class); - initVersionSetting(id, versionSetting); + VersionSetting versionSetting = JsonUtils.fromJsonFile(file, VersionSetting.class); + initLocalVersionSetting(id, versionSetting); } catch (Exception ex) { // If [JsonParseException], [IOException] or [NullPointerException] happens, the json file is malformed and needed to be recreated. - initVersionSetting(id, new VersionSetting()); + initLocalVersionSetting(id, new VersionSetting()); } } /** * Create new version setting if version id has no version setting. + * * @param id the version id. * @return new version setting, null if given version does not exist. */ - public VersionSetting createVersionSetting(String id) { + public VersionSetting createLocalVersionSetting(String id) { if (!hasVersion(id)) return null; - if (versionSettings.containsKey(id)) - return getVersionSetting(id); + if (localVersionSettings.containsKey(id)) + return getLocalVersionSetting(id); else - return initVersionSetting(id, new VersionSetting()); + return initLocalVersionSetting(id, new VersionSetting()); } - private VersionSetting initVersionSetting(String id, VersionSetting vs) { - vs.addPropertyChangedListener(a -> saveVersionSetting(id)); - versionSettings.put(id, vs); + private VersionSetting initLocalVersionSetting(String id, VersionSetting vs) { + localVersionSettings.put(id, vs); + vs.addListener(a -> saveVersionSetting(id)); return vs; } @@ -142,80 +230,232 @@ private VersionSetting initVersionSetting(String id, VersionSetting vs) { * Get the version setting for version id. * * @param id version id - * - * @return may return null if the id not exists + * @return corresponding version setting, null if the version has no its own version setting. */ - public VersionSetting getVersionSetting(String id) { - if (!versionSettings.containsKey(id)) - loadVersionSetting(id); - VersionSetting setting = versionSettings.get(id); + @Nullable + public VersionSetting getLocalVersionSetting(String id) { + if (!localVersionSettings.containsKey(id)) + loadLocalVersionSetting(id); + VersionSetting setting = localVersionSettings.get(id); if (setting != null && isModpack(id)) - setting.setGameDirType(EnumGameDirectory.VERSION_FOLDER); + setting.setGameDirType(GameDirectoryType.VERSION_FOLDER); return setting; } - public File getVersionIconFile(String id) { - return new File(getVersionRoot(id), "icon.png"); + @Nullable + public VersionSetting getLocalVersionSettingOrCreate(String id) { + VersionSetting vs = getLocalVersionSetting(id); + if (vs == null) { + vs = createLocalVersionSetting(id); + } + return vs; + } + + public VersionSetting getVersionSetting(String id) { + VersionSetting vs = getLocalVersionSetting(id); + if (vs == null || vs.isUsesGlobal()) { + profile.getGlobal().setUsesGlobal(true); + return profile.getGlobal(); + } else + return vs; + } + + public Optional getVersionIconFile(String id) { + Path root = getVersionRoot(id); + + for (String extension : FXUtils.IMAGE_EXTENSIONS) { + Path file = root.resolve("icon." + extension); + if (Files.exists(file)) { + return Optional.of(file); + } + } + + return Optional.empty(); + } + + public void setVersionIconFile(String id, Path iconFile) throws IOException { + String ext = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT); + if (!FXUtils.IMAGE_EXTENSIONS.contains(ext)) { + throw new IllegalArgumentException("Unsupported icon file: " + ext); + } + + deleteIconFile(id); + + FileUtils.copyFile(iconFile, getVersionRoot(id).resolve("icon." + ext)); + } + + public void deleteIconFile(String id) { + Path root = getVersionRoot(id); + for (String extension : FXUtils.IMAGE_EXTENSIONS) { + Path file = root.resolve("icon." + extension); + try { + Files.deleteIfExists(file); + } catch (IOException e) { + LOG.warning("Failed to delete icon file: " + file, e); + } + } } public Image getVersionIconImage(String id) { if (id == null || !isLoaded()) - return new Image("/assets/img/grass.png"); - - Version version = getVersion(id); - File iconFile = getVersionIconFile(id); - if (iconFile.exists()) - return new Image("file:" + iconFile.getAbsolutePath()); - else if ("net.minecraft.launchwrapper.Launch".equals(version.getMainClass())) - return new Image("/assets/img/furnace.png"); - else - return new Image("/assets/img/grass.png"); - } + return VersionIconType.DEFAULT.getIcon(); + + VersionSetting vs = getLocalVersionSettingOrCreate(id); + VersionIconType iconType = vs != null ? Lang.requireNonNullElse(vs.getVersionIcon(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT; + + if (iconType == VersionIconType.DEFAULT) { + Version version = getVersion(id).resolve(this); + Optional iconFile = getVersionIconFile(id); + if (iconFile.isPresent()) { + try { + return FXUtils.loadImage(iconFile.get()); + } catch (Exception e) { + LOG.warning("Failed to load version icon of " + id, e); + } + } - public boolean saveVersionSetting(String id) { - if (!versionSettings.containsKey(id)) - return false; - File file = getVersionSettingFile(id); - if (!FileUtils.makeDirectory(file.getAbsoluteFile().getParentFile())) - return false; + if (LibraryAnalyzer.isModded(this, version)) { + LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(version, null); + if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) + return VersionIconType.FABRIC.getIcon(); + else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE)) + return VersionIconType.FORGE.getIcon(); + else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) + return VersionIconType.CLEANROOM.getIcon(); + else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) + return VersionIconType.NEO_FORGE.getIcon(); + else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT)) + return VersionIconType.QUILT.getIcon(); + else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) + return VersionIconType.OPTIFINE.getIcon(); + else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LITELOADER)) + return VersionIconType.CHICKEN.getIcon(); + else + return VersionIconType.FURNACE.getIcon(); + } + + return VersionIconType.DEFAULT.getIcon(); + } else { + return iconType.getIcon(); + } + } + public void saveVersionSetting(String id) { + if (!localVersionSettings.containsKey(id)) + return; + Path file = getLocalVersionSettingFile(id).toAbsolutePath().normalize(); try { - FileUtils.writeText(file, GSON.toJson(versionSettings.get(id))); - return true; + Files.createDirectories(file.getParent()); } catch (IOException e) { - Logging.LOG.log(Level.SEVERE, "Unable to save version setting of " + id, e); - return false; + LOG.warning("Failed to create directory: " + file.getParent(), e); } + + FileSaver.save(file, GSON.toJson(localVersionSettings.get(id))); } /** * Make version use self version settings instead of the global one. + * * @param id the version id. * @return specialized version setting, null if given version does not exist. */ public VersionSetting specializeVersionSetting(String id) { - VersionSetting vs = getVersionSetting(id); + VersionSetting vs = getLocalVersionSetting(id); if (vs == null) - vs = createVersionSetting(id); + vs = createLocalVersionSetting(id); if (vs == null) return null; - vs.setUsesGlobal(false); + if (vs.isUsesGlobal()) { + vs.setUsesGlobal(false); + } return vs; } public void globalizeVersionSetting(String id) { - VersionSetting vs = getVersionSetting(id); + VersionSetting vs = getLocalVersionSetting(id); if (vs != null) vs.setUsesGlobal(true); } - public boolean forbidsVersion(String id) { - return FORBIDDEN.contains(id); + public LaunchOptions getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List javaAgents, List javaArguments, boolean makeLaunchScript) { + VersionSetting vs = getVersionSetting(version); + + LaunchOptions.Builder builder = new LaunchOptions.Builder() + .setGameDir(gameDir) + .setJava(javaVersion) + .setVersionType(Metadata.TITLE) + .setVersionName(version) + .setProfileName(Metadata.TITLE) + .setGameArguments(StringUtils.tokenize(vs.getMinecraftArgs())) + .setOverrideJavaArguments(StringUtils.tokenize(vs.getJavaArgs())) + .setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int) (getAllocatedMemory( + vs.getMaxMemory() * 1024L * 1024L, + SystemInfo.getPhysicalMemoryStatus().getAvailable(), + vs.isAutoMemory() + ) / 1024 / 1024)) + .setMinMemory(vs.getMinMemory()) + .setMetaspace(Lang.toIntOrNull(vs.getPermSize())) + .setEnvironmentVariables( + Lang.mapOf(StringUtils.tokenize(vs.getEnvironmentVariables()) + .stream() + .map(it -> { + int idx = it.indexOf('='); + return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, ""); + }) + .collect(Collectors.toList()) + ) + ) + .setWidth(vs.getWidth()) + .setHeight(vs.getHeight()) + .setFullscreen(vs.isFullscreen()) + .setServerIp(vs.getServerIp()) + .setWrapper(vs.getWrapper()) + .setPreLaunchCommand(vs.getPreLaunchCommand()) + .setPostExitCommand(vs.getPostExitCommand()) + .setNoGeneratedJVMArgs(vs.isNoJVMArgs()) + .setNoGeneratedOptimizingJVMArgs(vs.isNoOptimizingJVMArgs()) + .setNativesDirType(vs.getNativesDirType()) + .setNativesDir(vs.getNativesDir()) + .setProcessPriority(vs.getProcessPriority()) + .setRenderer(vs.getRenderer()) + .setUseNativeGLFW(vs.isUseNativeGLFW()) + .setUseNativeOpenAL(vs.isUseNativeOpenAL()) + .setDaemon(!makeLaunchScript && vs.getLauncherVisibility().isDaemon()) + .setJavaAgents(javaAgents) + .setJavaArguments(javaArguments); + + if (config().hasProxy()) { + builder.setProxyType(config().getProxyType()); + builder.setProxyHost(config().getProxyHost()); + builder.setProxyPort(config().getProxyPort()); + + if (config().hasProxyAuth()) { + builder.setProxyUser(config().getProxyUser()); + builder.setProxyPass(config().getProxyPass()); + } + } + + Path json = getModpackConfiguration(version); + if (Files.exists(json)) { + try { + String jsonText = Files.readString(json); + ModpackConfiguration modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class); + ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType()); + if (provider != null) provider.injectLaunchOptions(jsonText, builder); + } catch (IOException | JsonParseException e) { + LOG.warning("Failed to parse modpack configuration file " + json, e); + } + } + + if (vs.isAutoMemory() && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx"))) + builder.setMaxMemory(null); + + return builder.create(); } @Override - public File getModpackConfiguration(String version) { - return new File(getVersionRoot(version), "modpack.cfg"); + public Path getModpackConfiguration(String version) { + return getVersionRoot(version).resolve("modpack.cfg"); } public void markVersionAsModpack(String id) { @@ -226,11 +466,82 @@ public void undoMark(String id) { beingModpackVersions.remove(id); } + public void markVersionLaunchedAbnormally(String id) { + try { + Files.createFile(getVersionRoot(id).resolve(".abnormal")); + } catch (IOException ignored) { + } + } + + public boolean unmarkVersionLaunchedAbnormally(String id) { + Path file = getVersionRoot(id).resolve(".abnormal"); + if (Files.isRegularFile(file)) { + try { + Files.delete(file); + } catch (IOException e) { + LOG.warning("Failed to delete abnormal mark file: " + file, e); + } + + return true; + } else { + return false; + } + } + private static final Gson GSON = new GsonBuilder() .setPrettyPrinting() .create(); - private static final HashSet FORBIDDEN = new HashSet<>(Arrays.asList("modpack", "minecraftinstance", "manifest")); - private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}"; + + + // These version ids are forbidden because they may conflict with modpack configuration filenames + private static final Set FORBIDDEN_VERSION_IDS = new HashSet<>(Arrays.asList( + "modpack", "minecraftinstance", "manifest")); + + public static boolean isValidVersionId(String id) { + if (FORBIDDEN_VERSION_IDS.contains(id)) + return false; + + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && + FORBIDDEN_VERSION_IDS.contains(id.toLowerCase(Locale.ROOT))) + return false; + + return FileUtils.isNameValid(id); + } + + /** + * Returns true if the given version id conflicts with an existing version. + */ + public boolean versionIdConflicts(String id) { + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { + // on Windows, filenames are case-insensitive + for (String existingId : versions.keySet()) { + if (existingId.equalsIgnoreCase(id)) { + return true; + } + } + return false; + } else { + return versions.containsKey(id); + } + } + + public static long getAllocatedMemory(long minimum, long available, boolean auto) { + if (auto) { + available -= 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself + if (available <= 0) { + return minimum; + } + + final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB + final long suggested = Math.min(available <= threshold + ? (long) (available * 0.8) + : (long) (threshold * 0.8 + (available - threshold) * 0.2), + 16L * 1024 * 1024 * 1024); + return Math.max(minimum, suggested); + } else { + return minimum; + } + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackExportTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackExportTask.java deleted file mode 100644 index 3b01f30a0e..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackExportTask.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.game; - -import org.jackhuang.hmcl.mod.Modpack; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.Logging; -import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jackhuang.hmcl.util.io.Zipper; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; - -/** - * Export the game to a mod pack file. - */ -public class HMCLModpackExportTask extends Task { - private final DefaultGameRepository repository; - private final String version; - private final List whitelist; - private final Modpack modpack; - private final File output; - - /** - * @param output mod pack file. - * @param version to locate version.json - */ - public HMCLModpackExportTask(DefaultGameRepository repository, String version, List whitelist, Modpack modpack, File output) { - this.repository = repository; - this.version = version; - this.whitelist = whitelist; - this.modpack = modpack; - this.output = output; - - onDone().register(event -> { - if (event.isFailed()) output.delete(); - }); - } - - @Override - public void execute() throws Exception { - ArrayList blackList = new ArrayList<>(HMCLModpackManager.MODPACK_BLACK_LIST); - blackList.add(version + ".jar"); - blackList.add(version + ".json"); - Logging.LOG.info("Compressing game files without some files in blacklist, including files or directories: usernamecache.json, asm, logs, backups, versions, assets, usercache.json, libraries, crash-reports, launcher_profiles.json, NVIDIA, TCNodeTracker"); - try (Zipper zip = new Zipper(output.toPath())) { - zip.putDirectory(repository.getRunDirectory(version).toPath(), "minecraft", path -> { - if (path.isEmpty()) - return true; - for (String s : blackList) - if (path.equals(s)) - return false; - for (String s : whitelist) - if (path.equals(s)) - return true; - return false; - }); - - Version mv = repository.getResolvedVersion(version); - String gameVersion = GameVersion.minecraftVersion(repository.getVersionJar(version)) - .orElseThrow(() -> new IllegalStateException("Cannot parse the version of " + version)); - zip.putTextFile(JsonUtils.GSON.toJson(mv.setJar(gameVersion)), "minecraft/pack.json"); // Making "jar" to gameVersion is to be compatible with old HMCL. - zip.putTextFile(JsonUtils.GSON.toJson(modpack.setGameVersion(gameVersion)), "modpack.json"); // Newer HMCL only reads 'gameVersion' field. - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java index b86ceb340c..5a333eed3f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,9 +18,8 @@ package org.jackhuang.hmcl.game; import com.google.gson.JsonParseException; -import com.google.gson.reflect.TypeToken; -import org.jackhuang.hmcl.download.DependencyManager; -import org.jackhuang.hmcl.download.game.VersionJsonSaveTask; +import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.mod.MinecraftInstanceTask; import org.jackhuang.hmcl.mod.Modpack; import org.jackhuang.hmcl.mod.ModpackConfiguration; @@ -29,31 +28,33 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; -import org.jackhuang.hmcl.util.io.FileUtils; -import java.io.File; import java.io.IOException; -import java.util.LinkedList; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; -public final class HMCLModpackInstallTask extends Task { - private final File zipFile; +public final class HMCLModpackInstallTask extends Task { + private final Path zipFile; private final String name; private final HMCLGameRepository repository; + private final DefaultDependencyManager dependency; private final Modpack modpack; - private final List dependencies = new LinkedList<>(); - private final List dependents = new LinkedList<>(); + private final List> dependencies = new ArrayList<>(1); + private final List> dependents = new ArrayList<>(4); - public HMCLModpackInstallTask(Profile profile, File zipFile, Modpack modpack, String name) { - DependencyManager dependency = profile.getDependency(); + public HMCLModpackInstallTask(Profile profile, Path zipFile, Modpack modpack, String name) { + dependency = profile.getDependency(); repository = profile.getRepository(); this.zipFile = zipFile; this.name = name; this.modpack = modpack; - File run = repository.getRunDirectory(name); - File json = repository.getModpackConfiguration(name); - if (repository.hasVersion(name) && !json.exists()) + Path run = repository.getRunDirectory(name); + Path json = repository.getModpackConfiguration(name); + if (repository.hasVersion(name) && Files.notExists(json)) throw new IllegalArgumentException("Version " + name + " already exists"); dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync()); @@ -64,35 +65,42 @@ public HMCLModpackInstallTask(Profile profile, File zipFile, Modpack modpack, St ModpackConfiguration config = null; try { - if (json.exists()) { - config = JsonUtils.GSON.fromJson(FileUtils.readText(json), new TypeToken>() { - }.getType()); + if (Files.exists(json)) { + config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class)); - if (!MODPACK_TYPE.equals(config.getType())) + if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType())) throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version."); } } catch (JsonParseException | IOException ignore) { } - dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), "/minecraft", it -> !"pack.json".equals(it), config)); + dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/minecraft"), it -> !"pack.json".equals(it), config)); + dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack")); } @Override - public List getDependencies() { + public List> getDependencies() { return dependencies; } @Override - public List getDependents() { + public List> getDependents() { return dependents; } @Override public void execute() throws Exception { String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json"); - Version version = JsonUtils.GSON.fromJson(json, Version.class).setId(name).setJar(null); - dependencies.add(new VersionJsonSaveTask(repository, version)); - dependencies.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), "/minecraft", modpack, MODPACK_TYPE, repository.getModpackConfiguration(name))); - } + Version originalVersion = JsonUtils.GSON.fromJson(json, Version.class).setId(name).setJar(null); + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalVersion, null); + Task libraryTask = Task.supplyAsync(() -> originalVersion); + // reinstall libraries + // libraries of Forge and OptiFine should be obtained by installation. + for (LibraryAnalyzer.LibraryMark mark : analyzer) { + if (LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(mark.getLibraryId())) + continue; + libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.getLibraryId(), mark.getLibraryVersion())); + } - public static final String MODPACK_TYPE = "HMCL"; + dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync)); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackManager.java deleted file mode 100644 index 9dc08f51f6..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackManager.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.game; - -import com.google.gson.JsonParseException; -import org.jackhuang.hmcl.mod.Modpack; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jackhuang.hmcl.util.io.CompressingUtils; - -import java.io.IOException; -import java.nio.charset.Charset; -import java.nio.file.Path; -import java.util.List; - -/** - * @author huangyuhui - */ -public final class HMCLModpackManager { - - public static final List MODPACK_BLACK_LIST = Lang.immutableListOf( - "usernamecache.json", "usercache.json", // Minecraft - "launcher_profiles.json", "launcher.pack.lzma", // Minecraft Launcher - "pack.json", "launcher.jar", "hmclmc.log", "cache", // HMCL - "manifest.json", "minecraftinstance.json", ".curseclient", // Curse - "minetweaker.log", // Mods - "jars", "logs", "versions", "assets", "libraries", "crash-reports", "NVIDIA", "AMD", "screenshots", "natives", "native", "$native", "server-resource-packs", // Minecraft - "downloads", // Curse - "asm", "backups", "TCNodeTracker", "CustomDISkins", "data" // Mods - ); - public static final List MODPACK_SUGGESTED_BLACK_LIST = Lang.immutableListOf( - "fonts", // BetterFonts - "saves", "servers.dat", "options.txt", // Minecraft - "blueprints" /* BuildCraft */, - "optionsof.txt" /* OptiFine */, - "journeymap" /* JourneyMap */, - "optionsshaders.txt", - "mods/VoxelMods"); - - public static ModAdviser.ModSuggestion suggestMod(String fileName, boolean isDirectory) { - if (match(MODPACK_BLACK_LIST, fileName, isDirectory)) - return ModAdviser.ModSuggestion.HIDDEN; - if (match(MODPACK_SUGGESTED_BLACK_LIST, fileName, isDirectory)) - return ModAdviser.ModSuggestion.NORMAL; - else - return ModAdviser.ModSuggestion.SUGGESTED; - } - - private static boolean match(List l, String fileName, boolean isDirectory) { - for (String s : l) - if (isDirectory) { - if (fileName.startsWith(s + "/")) - return true; - } else if (fileName.equals(s)) - return true; - return false; - } - - /** - * Read the manifest in a HMCL modpack. - * - * @param file a HMCL modpack file. - * @param encoding encoding of modpack zip file. - * @throws IOException if the file is not a valid zip file. - * @throws JsonParseException if the manifest.json is missing or malformed. - * @return the manifest of HMCL modpack. - */ - public static Modpack readHMCLModpackManifest(Path file, Charset encoding) throws IOException, JsonParseException { - String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json", encoding); - Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, Modpack.class).setEncoding(encoding); - String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json", encoding); - Version game = JsonUtils.fromNonNullJson(gameJson, Version.class); - if (game.getJar() == null) - if (StringUtils.isBlank(manifest.getVersion())) - throw new JsonParseException("Cannot recognize the game version of modpack " + file + "."); - else - return manifest.setManifest(HMCLModpackManifest.INSTANCE); - else - return manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.getJar()); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackManifest.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackManifest.java index e7ea89c5ee..ba091d5ac3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackManifest.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackManifest.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,8 +17,16 @@ */ package org.jackhuang.hmcl.game; -public final class HMCLModpackManifest { +import org.jackhuang.hmcl.mod.ModpackManifest; +import org.jackhuang.hmcl.mod.ModpackProvider; + +public final class HMCLModpackManifest implements ModpackManifest { public static final HMCLModpackManifest INSTANCE = new HMCLModpackManifest(); private HMCLModpackManifest() {} + + @Override + public ModpackProvider getProvider() { + return HMCLModpackProvider.INSTANCE; + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java new file mode 100644 index 0000000000..cec33afcc6 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java @@ -0,0 +1,87 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2022 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import com.google.gson.JsonParseException; +import kala.compress.archivers.zip.ZipArchiveReader; +import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.mod.MismatchedModpackTypeException; +import org.jackhuang.hmcl.mod.Modpack; +import org.jackhuang.hmcl.mod.ModpackProvider; +import org.jackhuang.hmcl.mod.ModpackUpdateTask; +import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.CompressingUtils; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Path; + +public final class HMCLModpackProvider implements ModpackProvider { + public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider(); + + @Override + public String getName() { + return "HMCL"; + } + + @Override + public Task createCompletionTask(DefaultDependencyManager dependencyManager, String version) { + return null; + } + + @Override + public Task createUpdateTask(DefaultDependencyManager dependencyManager, String name, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + if (!(modpack.getManifest() instanceof HMCLModpackManifest)) + throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); + + if (!(dependencyManager.getGameRepository() instanceof HMCLGameRepository repository)) { + throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository"); + } + + Profile profile = repository.getProfile(); + + return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name)); + } + + @Override + public Modpack readManifest(ZipArchiveReader file, Path path, Charset encoding) throws IOException, JsonParseException { + String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json"); + Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding); + String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json"); + Version game = JsonUtils.fromNonNullJson(gameJson, Version.class); + if (game.getJar() == null) + if (StringUtils.isBlank(manifest.getVersion())) + throw new JsonParseException("Cannot recognize the game version of modpack " + file + "."); + else + manifest.setManifest(HMCLModpackManifest.INSTANCE); + else + manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.getJar()); + return manifest; + } + + private final static class HMCLModpack extends Modpack { + @Override + public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) { + return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name); + } + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index de641b771f..02a566e2d1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,61 +17,66 @@ */ package org.jackhuang.hmcl.game; -import javafx.application.Platform; +import com.jfoenix.controls.JFXButton; +import javafx.stage.Stage; import org.jackhuang.hmcl.Launcher; -import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.auth.AuthInfo; -import org.jackhuang.hmcl.auth.AuthenticationException; -import org.jackhuang.hmcl.auth.CredentialExpiredException; +import org.jackhuang.hmcl.auth.*; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.MaintainTask; -import org.jackhuang.hmcl.download.game.LibraryDownloadException; +import org.jackhuang.hmcl.download.game.*; +import org.jackhuang.hmcl.java.JavaManager; +import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.launch.*; -import org.jackhuang.hmcl.mod.CurseCompletionException; -import org.jackhuang.hmcl.mod.CurseCompletionTask; +import org.jackhuang.hmcl.mod.ModpackCompletionException; import org.jackhuang.hmcl.mod.ModpackConfiguration; -import org.jackhuang.hmcl.setting.LauncherVisibility; -import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.setting.VersionSetting; +import org.jackhuang.hmcl.mod.ModpackProvider; +import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.*; -import org.jackhuang.hmcl.ui.Controllers; -import org.jackhuang.hmcl.ui.DialogController; -import org.jackhuang.hmcl.ui.LogWindow; +import org.jackhuang.hmcl.ui.*; import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; -import org.jackhuang.hmcl.ui.construct.MessageBox; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; +import org.jackhuang.hmcl.ui.construct.PromptDialogPane; import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane; -import org.jackhuang.hmcl.util.Log4jLevel; -import org.jackhuang.hmcl.util.Logging; -import org.jackhuang.hmcl.util.Pair; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.function.ExceptionalSupplier; -import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; -import org.jackhuang.hmcl.util.platform.CommandBuilder; -import org.jackhuang.hmcl.util.platform.JavaVersion; -import org.jackhuang.hmcl.util.platform.ManagedProcess; -import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.*; +import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.io.ResponseCodeException; +import org.jackhuang.hmcl.util.platform.*; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jackhuang.hmcl.util.versioning.VersionNumber; -import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.nio.file.AccessDeniedException; +import java.nio.file.Path; import java.util.*; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.util.Lang.mapOf; -import static org.jackhuang.hmcl.util.Logging.LOG; -import static org.jackhuang.hmcl.util.Pair.pair; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import java.lang.ref.WeakReference; + +import static javafx.application.Platform.runLater; +import static javafx.application.Platform.setImplicitExit; +import static org.jackhuang.hmcl.ui.FXUtils.runInFX; +import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; +import static org.jackhuang.hmcl.util.Lang.resolveException; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import static org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM; +import static org.jackhuang.hmcl.util.platform.Platform.isCompatibleWithX86Java; public final class LauncherHelper { private final Profile profile; - private final Account account; + private Account account; private final String selectedVersion; - private File scriptFile; + private Path scriptFile; private final VersionSetting setting; private LauncherVisibility launcherVisibility; private boolean showLogs; @@ -83,142 +88,169 @@ public LauncherHelper(Profile profile, Account account, String selectedVersion) this.setting = profile.getVersionSetting(selectedVersion); this.launcherVisibility = setting.getLauncherVisibility(); this.showLogs = setting.isShowLogs(); + this.launchingStepsPane.setTitle(i18n("version.launch")); } - private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(it -> {}); + private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL); + + public Account getAccount() { + return account; + } + + public void setAccount(Account account) { + this.account = account; + } public void setTestMode() { launcherVisibility = LauncherVisibility.KEEP; showLogs = true; } + public void setKeep() { + launcherVisibility = LauncherVisibility.KEEP; + } + public void launch() { - Logging.LOG.info("Launching game version: " + selectedVersion); + FXUtils.checkFxUserThread(); - GameRepository repository = profile.getRepository(); - Version version = repository.getResolvedVersion(selectedVersion); + LOG.info("Launching game version: " + selectedVersion); - Platform.runLater(() -> { - try { - checkGameState(profile, setting, version, () -> { - Controllers.dialog(launchingStepsPane); - Schedulers.newThread().schedule(this::launch0); - }); - } catch (InterruptedException ignore) { - } - }); + Controllers.dialog(launchingStepsPane); + launch0(); } - public void makeLaunchScript(File scriptFile) { + public void makeLaunchScript(Path scriptFile) { this.scriptFile = Objects.requireNonNull(scriptFile); - launch(); } private void launch0() { + // https://github.com/HMCL-dev/HMCL/pull/4121 + PROCESSES.removeIf(it -> it.get() == null); + HMCLGameRepository repository = profile.getRepository(); DefaultDependencyManager dependencyManager = profile.getDependency(); - Version version = MaintainTask.maintain(repository.getResolvedVersion(selectedVersion)); - Optional gameVersion = GameVersion.minecraftVersion(repository.getVersionJar(version)); - - TaskExecutor executor = Task.of(Schedulers.javafx(), () -> emitStatus(LoadingState.DEPENDENCIES)) - .then(variables -> { + AtomicReference version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion))); + Optional gameVersion = repository.getGameVersion(version.get()); + boolean integrityCheck = repository.unmarkVersionLaunchedAbnormally(selectedVersion); + CountDownLatch launchingLatch = new CountDownLatch(1); + List javaAgents = new ArrayList<>(0); + List javaArguments = new ArrayList<>(0); + + AtomicReference javaVersionRef = new AtomicReference<>(); + + TaskExecutor executor = checkGameState(profile, setting, version.get()) + .thenComposeAsync(java -> { + javaVersionRef.set(Objects.requireNonNull(java)); + version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments)); if (setting.isNotCheckGame()) return null; - else - return dependencyManager.checkGameCompletionAsync(version); - }) - .then(Task.of(Schedulers.javafx(), () -> emitStatus(LoadingState.MODS))) - .then(var -> { - try { - ModpackConfiguration configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion)); - if ("Curse".equals(configuration.getType())) - return new CurseCompletionTask(dependencyManager, selectedVersion); - else - return null; - } catch (IOException e) { - return null; - } - }) - .then(Task.of(Schedulers.javafx(), () -> emitStatus(LoadingState.LOGGING_IN))) - .then(Task.of(i18n("account.methods"), variables -> { - try { - variables.set("account", account.logIn()); - } catch (CredentialExpiredException e) { - LOG.info("Credential has expired: " + e); - variables.set("account", DialogController.logIn(account)); - } catch (AuthenticationException e) { - LOG.warning("Authentication failed, try playing offline: " + e); - variables.set("account", - account.playOffline().orElseThrow(() -> e)); - } - })) - .then(Task.of(Schedulers.javafx(), () -> emitStatus(LoadingState.LAUNCHING))) - .then(Task.of(variables -> { - variables.set("launcher", new HMCLGameLauncher( + return Task.allOf( + dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck), + Task.composeAsync(() -> { + try { + ModpackConfiguration configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion)); + ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType()); + if (provider == null) return null; + else return provider.createCompletionTask(dependencyManager, selectedVersion); + } catch (IOException e) { + return null; + } + }), + Task.composeAsync(() -> { + Renderer renderer = setting.getRenderer(); + if (renderer != Renderer.DEFAULT && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { + Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION); + if (lib == null) + return null; + Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib); + if (file.toAbsolutePath().toString().indexOf('=') >= 0) { + LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader"); + return null; + } + + String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.name().toLowerCase(Locale.ROOT); + + if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) { + return new LibraryDownloadTask(dependencyManager, file, lib) + .thenRunAsync(() -> javaAgents.add(agent)); + } else { + javaAgents.add(agent); + return null; + } + } else { + return null; + } + }) + ); + }).withStage("launch.state.dependencies") + .thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null)) + .thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in")) + .thenComposeAsync(authInfo -> Task.supplyAsync(() -> { + LaunchOptions launchOptions = repository.getLaunchOptions( + selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null); + + LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModManager(selectedVersion).getModsDirectory(), 10)); + + return new HMCLGameLauncher( repository, - selectedVersion, - variables.get("account"), - setting.toLaunchOptions(profile.getGameDir()), + version.get(), + authInfo, + launchOptions, launcherVisibility == LauncherVisibility.CLOSE ? null // Unnecessary to start listening to game process output when close launcher immediately after game launched. - : new HMCLProcessListener(variables.get("account"), setting, gameVersion.isPresent()) - )); - })) - .then(variables -> { - DefaultLauncher launcher = variables.get("launcher"); + : new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.isPresent()) + ); + }).thenComposeAsync(launcher -> { // launcher is prev task's result if (scriptFile == null) { - return new LaunchTask<>(launcher::launch).setName(i18n("version.launch")); + return Task.supplyAsync(launcher::launch); } else { - return new LaunchTask<>(() -> { + return Task.supplyAsync(() -> { launcher.makeLaunchScript(scriptFile); return null; - }).setName(i18n("version.launch_script")); + }); } - }) - .then(Task.of(variables -> { + }).thenAcceptAsync(process -> { // process is LaunchTask's result if (scriptFile == null) { - ManagedProcess process = variables.get(LaunchTask.LAUNCH_ID); - PROCESSES.add(process); + PROCESSES.add(new WeakReference<>(process)); if (launcherVisibility == LauncherVisibility.CLOSE) Launcher.stopApplication(); else - launchingStepsPane.setCancel(it -> { + launchingStepsPane.setCancel(new TaskCancellationAction(it -> { process.stop(); it.fireEvent(new DialogCloseEvent()); - }); - } else - Platform.runLater(() -> { + })); + } else { + runLater(() -> { launchingStepsPane.fireEvent(new DialogCloseEvent()); - Controllers.dialog(i18n("version.launch_script.success", scriptFile.getAbsolutePath())); + Controllers.dialog(i18n("version.launch_script.success", FileUtils.getAbsolutePath(scriptFile))); }); - - })) + } + }).withFakeProgress( + i18n("message.doing"), + () -> launchingLatch.getCount() == 0, 6.95 + ).withStage("launch.state.waiting_launching")) + .withStagesHint(Lang.immutableListOf( + "launch.state.java", + "launch.state.dependencies", + "launch.state.logging_in", + "launch.state.waiting_launching")) .executor(); - launchingStepsPane.setExecutor(executor, false); executor.addTaskListener(new TaskListener() { - final AtomicInteger finished = new AtomicInteger(0); - - @Override - public void onFinished(Task task) { - finished.incrementAndGet(); - int runningTasks = executor.getRunningTasks(); - Platform.runLater(() -> launchingStepsPane.setProgress(1.0 * finished.get() / runningTasks)); - } @Override public void onStop(boolean success, TaskExecutor executor) { - if (!success && !Controllers.isStopped()) { - Platform.runLater(() -> { - // Check if the application has stopped - // because onStop will be invoked if tasks fail when the executor service shut down. - if (!Controllers.isStopped()) { - launchingStepsPane.fireEvent(new DialogCloseEvent()); - Exception ex = executor.getLastException(); - if (ex != null) { + runLater(() -> { + // Check if the application has stopped + // because onStop will be invoked if tasks fail when the executor service shut down. + if (!Controllers.isStopped()) { + launchingStepsPane.fireEvent(new DialogCloseEvent()); + if (!success) { + Exception ex = executor.getException(); + if (ex != null && !(ex instanceof CancellationException)) { String message; - if (ex instanceof CurseCompletionException) { + if (ex instanceof ModpackCompletionException) { if (ex.getCause() instanceof FileNotFoundException) message = i18n("modpack.type.curse.not_found"); else @@ -226,181 +258,430 @@ public void onStop(boolean success, TaskExecutor executor) { } else if (ex instanceof PermissionException) { message = i18n("launch.failed.executable_permission"); } else if (ex instanceof ProcessCreationException) { - message = i18n("launch.failed.creating_process") + ex.getLocalizedMessage(); + message = i18n("launch.failed.creating_process") + "\n" + ex.getLocalizedMessage(); } else if (ex instanceof NotDecompressingNativesException) { - message = i18n("launch.failed.decompressing_natives") + ex.getLocalizedMessage(); + message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage(); } else if (ex instanceof LibraryDownloadException) { - message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().getName()) + "\n" + StringUtils.getStackTrace(ex.getCause()); + message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().getName()) + "\n"; + if (ex.getCause() instanceof ResponseCodeException) { + ResponseCodeException rce = (ResponseCodeException) ex.getCause(); + int responseCode = rce.getResponseCode(); + String uri = rce.getUri(); + if (responseCode == 404) + message += i18n("download.code.404", uri); + else + message += i18n("download.failed", uri, responseCode); + } else { + message += StringUtils.getStackTrace(ex.getCause()); + } + } else if (ex instanceof DownloadException) { + URI uri = ((DownloadException) ex).getUri(); + if (ex.getCause() instanceof SocketTimeoutException) { + message = i18n("install.failed.downloading.timeout", uri); + } else if (ex.getCause() instanceof ResponseCodeException) { + ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause(); + if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) { + message = i18n("download.code." + responseCodeException.getResponseCode(), uri); + } else { + message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause()); + } + } else { + message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause()); + } + } else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) { + message = i18n("assets.index.malformed"); + } else if (ex instanceof AuthlibInjectorDownloadException) { + message = i18n("account.failed.injector_download_failure"); + } else if (ex instanceof CharacterDeletedException) { + message = i18n("account.failed.character_deleted"); + } else if (ex instanceof ResponseCodeException) { + ResponseCodeException rce = (ResponseCodeException) ex; + int responseCode = rce.getResponseCode(); + String uri = rce.getUri(); + if (responseCode == 404) + message = i18n("download.code.404", uri); + else + message = i18n("download.failed", uri, responseCode); + } else if (ex instanceof CommandTooLongException) { + message = i18n("launch.failed.command_too_long"); + } else if (ex instanceof ExecutionPolicyLimitException) { + Controllers.prompt(new PromptDialogPane.Builder(i18n("launch.failed.execution_policy"), + (result, resolve, reject) -> { + if (CommandBuilder.setExecutionPolicy()) { + LOG.info("Set the ExecutionPolicy for the scope 'CurrentUser' to 'RemoteSigned'"); + resolve.run(); + } else { + LOG.warning("Failed to set ExecutionPolicy"); + reject.accept(i18n("launch.failed.execution_policy.failed_to_set")); + } + }) + .addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("launch.failed.execution_policy.hint"))) + ); + + return; + } else if (ex instanceof AccessDeniedException) { + message = i18n("exception.access_denied", ((AccessDeniedException) ex).getFile()); } else { message = StringUtils.getStackTrace(ex); } Controllers.dialog(message, scriptFile == null ? i18n("launch.failed") : i18n("version.launch_script.failed"), - MessageBox.ERROR_MESSAGE); + MessageType.ERROR); } } - }); - } - launchingStepsPane.setExecutor(null); + } + launchingStepsPane.setExecutor(null); + }); } }); executor.start(); } - private static void checkGameState(Profile profile, VersionSetting setting, Version version, Runnable onAccept) throws InterruptedException { - if (setting.isNotCheckJVM()) { - onAccept.run(); - return; - } + private static Task checkGameState(Profile profile, VersionSetting setting, Version version) { + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, profile.getRepository().getGameVersion(version).orElse(null)); + GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT)); - boolean flag = false; - boolean java8required = false; - boolean newJavaRequired = false; + Task getJavaTask = Task.supplyAsync(() -> { + try { + return setting.getJava(gameVersion, version); + } catch (InterruptedException e) { + throw new CancellationException(); + } + }); + Task task; + if (setting.isNotCheckJVM()) { + task = getJavaTask.thenApplyAsync(java -> Lang.requireNonNullElse(java, JavaRuntime.getDefault())); + } else if (setting.getJavaVersionType() == JavaVersionType.AUTO || setting.getJavaVersionType() == JavaVersionType.VERSION) { + task = getJavaTask.thenComposeAsync(Schedulers.javafx(), java -> { + if (java != null) { + return Task.completed(java); + } - // Without onAccept called, the launching operation will be terminated. + // Reset invalid java version + CompletableFuture future = new CompletableFuture<>(); + Task result = Task.fromCompletableFuture(future); + Runnable breakAction = () -> future.completeExceptionally(new CancellationException("No accepted java")); + List supportedVersions = GameJavaVersion.getSupportedVersions(SYSTEM_PLATFORM); - VersionNumber gameVersion = VersionNumber.asVersion(GameVersion.minecraftVersion(profile.getRepository().getVersionJar(version)).orElse("Unknown")); - JavaVersion java = setting.getJavaVersion(); - if (java == null) { - Controllers.dialog(i18n("launch.wrong_javadir"), i18n("message.warning"), MessageBox.WARNING_MESSAGE, onAccept); - setting.setJava(null); - setting.setDefaultJavaPath(null); - java = JavaVersion.fromCurrentEnvironment(); - flag = true; - } + GameJavaVersion targetJavaVersion = null; + if (setting.getJavaVersionType() == JavaVersionType.VERSION) { + try { + int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion()); + GameJavaVersion minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion); + + if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.getMajorVersion()) { + Controllers.dialog( + i18n("launch.failed.java_version_too_low"), + i18n("message.error"), + MessageType.ERROR, + breakAction + ); + return result; + } - // Game later than 1.7.2 accepts Java 8. - if (!flag && java.getParsedVersion() < JavaVersion.JAVA_8 && gameVersion.compareTo(VersionNumber.asVersion("1.7.2")) > 0) { - Optional java8 = JavaVersion.getJavas().stream() - .filter(javaVersion -> javaVersion.getParsedVersion() >= JavaVersion.JAVA_8) - .max(Comparator.comparing(JavaVersion::getVersionNumber)); - if (java8.isPresent()) { - newJavaRequired = true; - setting.setJavaVersion(java8.get()); - } else { - if (gameVersion.compareTo(VersionNumber.asVersion("1.13")) >= 0) { - // Minecraft 1.13 and later versions only support Java 8 or later. - // Terminate launching operation. - Controllers.dialog(i18n("launch.advice.java8_1_13"), i18n("message.error"), MessageBox.ERROR_MESSAGE, null); + targetJavaVersion = GameJavaVersion.get(targetJavaVersionMajor); + } catch (NumberFormatException ignored) { + } + } else + targetJavaVersion = version.getJavaVersion(); + + if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) { + downloadJava(targetJavaVersion, profile) + .whenCompleteAsync((downloadedJava, exception) -> { + if (exception == null) { + future.complete(downloadedJava); + } else { + LOG.warning("Failed to download java", exception); + Controllers.confirm(i18n("launch.failed.no_accepted_java"), i18n("message.warning"), MessageType.WARNING, + () -> future.complete(JavaRuntime.getDefault()), + breakAction); + } + }, Schedulers.javafx()); } else { - // Most mods require Java 8 or later version. - Controllers.dialog(i18n("launch.advice.newer_java"), i18n("message.warning"), MessageBox.WARNING_MESSAGE, onAccept); + Controllers.confirm(i18n("launch.failed.no_accepted_java"), i18n("message.warning"), MessageType.WARNING, + () -> future.complete(JavaRuntime.getDefault()), + breakAction); } - flag = true; - } - } - // LaunchWrapper 1.12 will crash because of assuming the system class loader is an instance of URLClassLoader. - if (!flag && java.getParsedVersion() >= JavaVersion.JAVA_9 - && version.getMainClass().contains("launchwrapper") - && version.getLibraries().stream() - .filter(library -> "launchwrapper".equals(library.getArtifactId())) - .anyMatch(library -> VersionNumber.asVersion(library.getVersion()).compareTo(VersionNumber.asVersion("1.13")) < 0)) { - Optional java8 = JavaVersion.getJavas().stream().filter(javaVersion -> javaVersion.getParsedVersion() == JavaVersion.JAVA_8).findAny(); - if (java8.isPresent()) { - java8required = true; - setting.setJavaVersion(java8.get()); - Controllers.dialog(i18n("launch.advice.java9") + "\n" + i18n("launch.advice.corrected"), i18n("message.info"), MessageBox.INFORMATION_MESSAGE, onAccept); - flag = true; - } else { - Controllers.dialog(i18n("launch.advice.java9") + "\n" + i18n("launch.advice.uncorrected"), i18n("message.error"), MessageBox.ERROR_MESSAGE, null); - flag = true; - } - } + return result; + }); + } else { + task = getJavaTask.thenComposeAsync(java -> { + Set violatedMandatoryConstraints = EnumSet.noneOf(JavaVersionConstraint.class); + Set violatedSuggestedConstraints = EnumSet.noneOf(JavaVersionConstraint.class); + + if (java != null) { + for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) { + if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) { + if (!constraint.checkJava(gameVersion, version, java)) { + if (constraint.isMandatory()) { + violatedMandatoryConstraints.add(constraint); + } else { + violatedSuggestedConstraints.add(constraint); + } + } + } + } + } - // Minecraft 1.13 may crash when generating world on Java 8 earlier than 1.8.0_51 - VersionNumber JAVA_8 = VersionNumber.asVersion("1.8.0_51"); - if (!flag && gameVersion.compareTo(VersionNumber.asVersion("1.13")) >= 0 && java.getParsedVersion() == JavaVersion.JAVA_8 && java.getVersionNumber().compareTo(JAVA_8) < 0) { - Optional java8 = JavaVersion.getJavas().stream() - .filter(javaVersion -> javaVersion.getVersionNumber().compareTo(JAVA_8) >= 0) - .max(Comparator.comparing(JavaVersion::getVersionNumber)); - if (java8.isPresent()) { - newJavaRequired = true; - setting.setJavaVersion(java8.get()); - } else { - Controllers.dialog(i18n("launch.advice.java8_51_1_13"), i18n("message.warning"), MessageBox.WARNING_MESSAGE, onAccept); - flag = true; - } - } + CompletableFuture future = new CompletableFuture<>(); + Task result = Task.fromCompletableFuture(future); + Runnable breakAction = () -> future.completeExceptionally(new CancellationException("Launch operation was cancelled by user")); + + if (java == null || !violatedMandatoryConstraints.isEmpty()) { + JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, version); + if (suggestedJava != null) { + FXUtils.runInFX(() -> { + Controllers.confirm(i18n("launch.advice.java.auto"), i18n("message.warning"), () -> { + setting.setJavaAutoSelected(); + future.complete(suggestedJava); + }, breakAction); + }); + return result; + } else if (java == null) { + FXUtils.runInFX(() -> Controllers.dialog( + i18n("launch.invalid_java"), + i18n("message.error"), + MessageType.ERROR, + breakAction + )); + return result; + } else { + GameJavaVersion gameJavaVersion; + if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM_JAVA_21)) + gameJavaVersion = GameJavaVersion.JAVA_21; + else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON)) + gameJavaVersion = version.getJavaVersion(); + else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA)) + gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion); + else + gameJavaVersion = null; - if (!flag && java.getPlatform() == org.jackhuang.hmcl.util.platform.Platform.BIT_32 && - org.jackhuang.hmcl.util.platform.Platform.IS_64_BIT) { - final JavaVersion java32 = java; - - // First find if same java version but whose platform is 64-bit installed. - Optional java64 = JavaVersion.getJavas().stream() - .filter(javaVersion -> javaVersion.getPlatform() == org.jackhuang.hmcl.util.platform.Platform.PLATFORM) - .filter(javaVersion -> javaVersion.getParsedVersion() == java32.getParsedVersion()) - .max(Comparator.comparing(JavaVersion::getVersionNumber)); - - if (!java64.isPresent()) { - final boolean java8requiredFinal = java8required, newJavaRequiredFinal = newJavaRequired; - - // Then find if other java version which satisfies requirements installed. - java64 = JavaVersion.getJavas().stream() - .filter(javaVersion -> javaVersion.getPlatform() == org.jackhuang.hmcl.util.platform.Platform.PLATFORM) - .filter(javaVersion -> { - if (java8requiredFinal) return javaVersion.getParsedVersion() == JavaVersion.JAVA_8; - if (newJavaRequiredFinal) return javaVersion.getParsedVersion() >= JavaVersion.JAVA_8; - return true; - }) - .max(Comparator.comparing(JavaVersion::getVersionNumber)); - } + if (gameJavaVersion != null) { + FXUtils.runInFX(() -> downloadJava(gameJavaVersion, profile).whenCompleteAsync((downloadedJava, throwable) -> { + if (throwable == null) { + setting.setJavaAutoSelected(); + future.complete(downloadedJava); + } else { + LOG.warning("Failed to download java", throwable); + breakAction.run(); + } + }, Schedulers.javafx())); + return result; + } - if (java64.isPresent()) { - setting.setJavaVersion(java64.get()); - } else { - Controllers.dialog(i18n("launch.advice.different_platform"), i18n("message.error"), MessageBox.ERROR_MESSAGE, onAccept); - flag = true; - } - } + if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA_LINUX_JAVA_8)) { + if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER) { + FXUtils.runInFX(() -> Controllers.dialog(i18n("launch.advice.vanilla_linux_java_8"), i18n("message.error"), MessageType.ERROR, breakAction)); + return result; + } else { + violatedMandatoryConstraints.remove(JavaVersionConstraint.VANILLA_LINUX_JAVA_8); + } + } - // 32-bit JVM cannot make use of too much memory. - if (!flag && java.getPlatform() == org.jackhuang.hmcl.util.platform.Platform.BIT_32 && - setting.getMaxMemory() > 1.5 * 1024) { - // 1.5 * 1024 is an inaccurate number. - // Actual memory limit depends on operating system and memory. - Controllers.dialog(i18n("launch.advice.too_large_memory_for_32bit"), i18n("message.error"), MessageBox.ERROR_MESSAGE, onAccept); - flag = true; - } + if (violatedMandatoryConstraints.contains(JavaVersionConstraint.LAUNCH_WRAPPER)) { + FXUtils.runInFX(() -> Controllers.dialog( + i18n("launch.advice.java9") + "\n" + i18n("launch.advice.uncorrected"), + i18n("message.error"), + MessageType.ERROR, + breakAction + )); + return result; + } - // Cannot allocate too much memory exceeding free space. - if (!flag && OperatingSystem.TOTAL_MEMORY > 0 && OperatingSystem.TOTAL_MEMORY < setting.getMaxMemory()) { - Controllers.dialog(i18n("launch.advice.not_enough_space", OperatingSystem.TOTAL_MEMORY), i18n("message.error"), MessageBox.ERROR_MESSAGE, onAccept); - flag = true; - } + if (!violatedMandatoryConstraints.isEmpty()) { + FXUtils.runInFX(() -> Controllers.dialog( + i18n("launch.advice.unknown") + "\n" + violatedMandatoryConstraints, + i18n("message.error"), + MessageType.ERROR, + breakAction + )); + return result; + } + } + } - // Forge 2760~2773 will crash game with LiteLoader. - if (!flag) { - boolean hasForge2760 = version.getLibraries().stream().filter(it -> it.is("net.minecraftforge", "forge")) - .anyMatch(it -> - VersionNumber.VERSION_COMPARATOR.compare("1.12.2-14.23.5.2760", it.getVersion()) <= 0 && - VersionNumber.VERSION_COMPARATOR.compare(it.getVersion(), "1.12.2-14.23.5.2773") < 0); - boolean hasLiteLoader = version.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader")); - if (hasForge2760 && hasLiteLoader && gameVersion.compareTo(VersionNumber.asVersion("1.12.2")) == 0) { - Controllers.dialog(i18n("launch.advice.forge2760_liteloader"), i18n("message.error"), MessageBox.ERROR_MESSAGE, onAccept); - flag = true; - } + List suggestions = new ArrayList<>(); + + if (Architecture.SYSTEM_ARCH == Architecture.X86_64 && java.getPlatform().getArchitecture() == Architecture.X86) { + suggestions.add(i18n("launch.advice.different_platform")); + } + + // 32-bit JVM cannot make use of too much memory. + if (java.getBits() == Bits.BIT_32 && setting.getMaxMemory() > 1.5 * 1024) { + // 1.5 * 1024 is an inaccurate number. + // Actual memory limit depends on operating system and memory. + suggestions.add(i18n("launch.advice.too_large_memory_for_32bit")); + } + + for (JavaVersionConstraint violatedSuggestedConstraint : violatedSuggestedConstraints) { + switch (violatedSuggestedConstraint) { + case MODDED_JAVA_7: + suggestions.add(i18n("launch.advice.java.modded_java_7")); + break; + case MODDED_JAVA_8: + // Minecraft>=1.7.10+Forge accepts Java 8 + if (java.getParsedVersion() < 8) + suggestions.add(i18n("launch.advice.newer_java")); + else + suggestions.add(i18n("launch.advice.modded_java", 8, gameVersion)); + break; + case MODDED_JAVA_16: + // Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17 + String forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE).orElse(null); + if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0) + suggestions.add(i18n("launch.advice.forge37_0_60")); + else + suggestions.add(i18n("launch.advice.modded_java", 16, gameVersion)); + break; + case MODDED_JAVA_17: + suggestions.add(i18n("launch.advice.modded_java", 17, gameVersion)); + break; + case MODDED_JAVA_21: + suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion)); + break; + case CLEANROOM_JAVA_21: + suggestions.add(i18n("launch.advice.cleanroom")); + break; + case VANILLA_JAVA_8_51: + suggestions.add(i18n("launch.advice.java8_51_1_13")); + break; + case MODLAUNCHER_8: + suggestions.add(i18n("launch.advice.modlauncher8")); + break; + case VANILLA_X86: + if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER + && isCompatibleWithX86Java()) { + suggestions.add(i18n("launch.advice.vanilla_x86.translation")); + } + break; + default: + suggestions.add(violatedSuggestedConstraint.name()); + } + } + + // Cannot allocate too much memory exceeding free space. + long totalMemorySizeMB = (long) MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize()); + if (totalMemorySizeMB > 0 && totalMemorySizeMB < setting.getMaxMemory()) { + suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB)); + } + + VersionNumber forgeVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE) + .map(VersionNumber::asVersion) + .orElse(null); + + // Forge 2760~2773 will crash game with LiteLoader. + boolean hasForge2760 = forgeVersion != null && (forgeVersion.compareTo("1.12.2-14.23.5.2760") >= 0) && (forgeVersion.compareTo("1.12.2-14.23.5.2773") < 0); + boolean hasLiteLoader = version.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader")); + if (hasForge2760 && hasLiteLoader && gameVersion.compareTo("1.12.2") == 0) { + suggestions.add(i18n("launch.advice.forge2760_liteloader")); + } + + // OptiFine 1.14.4 is not compatible with Forge 28.2.2 and later versions. + boolean hasForge28_2_2 = forgeVersion != null && (forgeVersion.compareTo("1.14.4-28.2.2") >= 0); + boolean hasOptiFine = version.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine")); + if (hasForge28_2_2 && hasOptiFine && gameVersion.compareTo("1.14.4") == 0) { + suggestions.add(i18n("launch.advice.forge28_2_2_optifine")); + } + + if (suggestions.isEmpty()) { + if (!future.isDone()) { + future.complete(java); + } + } else { + String message; + if (suggestions.size() == 1) { + message = i18n("launch.advice", suggestions.get(0)); + } else { + message = i18n("launch.advice.multi", suggestions.stream().map(it -> "→ " + it).collect(Collectors.joining("\n"))); + } + + FXUtils.runInFX(() -> Controllers.confirm( + message, + i18n("message.warning"), + MessageType.WARNING, + () -> future.complete(java), + breakAction)); + } + + return result; + }); } - if (!flag) - onAccept.run(); + return task.withStage("launch.state.java"); } - public void emitStatus(LoadingState state) { - if (state == LoadingState.DONE) { - launchingStepsPane.fireEvent(new DialogCloseEvent()); - } + private static CompletableFuture downloadJava(GameJavaVersion javaVersion, Profile profile) { + CompletableFuture future = new CompletableFuture<>(); + Controllers.dialog(new MessageDialogPane.Builder( + i18n("launch.advice.require_newer_java_version", javaVersion.getMajorVersion()), + i18n("message.warning"), + MessageType.QUESTION) + .yesOrNo(() -> { + DownloadProvider downloadProvider = profile.getDependency().getDownloadProvider(); + Controllers.taskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + future.complete(result); + } else { + Throwable resolvedException = resolveException(exception); + LOG.warning("Failed to download java", exception); + if (!(resolvedException instanceof CancellationException)) { + Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed")); + } + future.completeExceptionally(new CancellationException()); + } + }), i18n("download.java"), new TaskCancellationAction(() -> future.completeExceptionally(new CancellationException()))); + }, () -> future.completeExceptionally(new CancellationException())).build()); - launchingStepsPane.setTitle(state.getLocalizedMessage()); - launchingStepsPane.setSubtitle((state.ordinal() + 1) + " / " + LoadingState.values().length); + return future; + } + + private static Task logIn(Account account) { + return Task.composeAsync(() -> { + try { + return Task.completed(account.logIn()); + } catch (CredentialExpiredException e) { + LOG.info("Credential has expired", e); + + return Task.completed(DialogController.logIn(account)); + } catch (AuthenticationException e) { + LOG.warning("Authentication failed, try skipping refresh", e); + + CompletableFuture> future = new CompletableFuture<>(); + runInFX(() -> { + JFXButton loginOfflineButton = new JFXButton(i18n("account.login.skip")); + loginOfflineButton.setOnAction(event -> { + try { + future.complete(Task.completed(account.playOffline())); + } catch (AuthenticationException e2) { + future.completeExceptionally(e2); + } + }); + JFXButton retryButton = new JFXButton(i18n("account.login.retry")); + retryButton.setOnAction(event -> { + future.complete(logIn(account)); + }); + Controllers.dialog(new MessageDialogPane.Builder(i18n("account.failed.server_disconnected"), i18n("account.failed"), MessageType.ERROR) + .addAction(loginOfflineButton) + .addAction(retryButton) + .addCancel(() -> + future.completeExceptionally(new CancellationException())) + .build()); + }); + return Task.fromCompletableFuture(future).thenComposeAsync(task -> task); + } + }); } private void checkExit() { switch (launcherVisibility) { case HIDE_AND_REOPEN: - Platform.runLater(Controllers.getStage()::show); + runLater(() -> { + Optional.ofNullable(Controllers.getStage()) + .ifPresent(Stage::show); + }); break; case KEEP: // No operations here @@ -408,9 +689,9 @@ private void checkExit() { case CLOSE: throw new Error("Never get to here"); case HIDE: - Platform.runLater(() -> { + runLater(() -> { // Shut down the platform when user closed log window. - Platform.setImplicitExit(true); + setImplicitExit(true); // If we use Launcher.stop(), log window will be halt immediately. Launcher.stopWithoutPlatform(); }); @@ -418,79 +699,114 @@ private void checkExit() { } } - private static class LaunchTask extends TaskResult { - private final ExceptionalSupplier supplier; - - public LaunchTask(ExceptionalSupplier supplier) { - this.supplier = supplier; - } - - @Override - public void execute() throws Exception { - setResult(supplier.get()); - } - - @Override - public String getId() { - return LAUNCH_ID; - } - - static final String LAUNCH_ID = "launch"; - } - /** * The managed process listener. * Guarantee that one [JavaProcess], one [HMCLProcessListener]. * Because every time we launched a game, we generates a new [HMCLProcessListener] */ - class HMCLProcessListener implements ProcessListener { + private final class HMCLProcessListener implements ProcessListener { - private final VersionSetting setting; - private final Map forbiddenTokens; + private final HMCLGameRepository repository; + private final Version version; + private final LaunchOptions launchOptions; private ManagedProcess process; - private boolean lwjgl; + private volatile boolean lwjgl; private LogWindow logWindow; private final boolean detectWindow; - private final LinkedList> logs; - private final CountDownLatch latch = new CountDownLatch(1); - - public HMCLProcessListener(AuthInfo authInfo, VersionSetting setting, boolean detectWindow) { - this.setting = setting; + private final CircularArrayList logs; + private final CountDownLatch launchingLatch; + private final String forbiddenAccessToken; + private Thread submitLogThread; + private LinkedBlockingQueue logBuffer; + + public HMCLProcessListener(HMCLGameRepository repository, Version version, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) { + this.repository = repository; + this.version = version; + this.launchOptions = launchOptions; + this.launchingLatch = launchingLatch; this.detectWindow = detectWindow; - - if (authInfo == null) - forbiddenTokens = Collections.emptyMap(); - else - forbiddenTokens = mapOf( - pair(authInfo.getAccessToken(), ""), - pair(UUIDTypeAdapter.fromUUID(authInfo.getUUID()), ""), - pair(authInfo.getUsername(), "") - ); - - logs = new LinkedList<>(); + this.forbiddenAccessToken = authInfo != null ? authInfo.getAccessToken() : null; + this.logs = new CircularArrayList<>(Log.getLogLines() + 1); } @Override public void setProcess(ManagedProcess process) { this.process = process; - if (showLogs) - Platform.runLater(() -> { - logWindow = new LogWindow(); + String command = new CommandBuilder().addAll(process.getCommands()).toString(); + + LOG.info("Launched process: " + command); + + String classpath = process.getClasspath(); + if (classpath != null) { + LOG.info("Process ClassPath: " + classpath); + } + + if (showLogs) { + CountDownLatch logWindowLatch = new CountDownLatch(1); + runLater(() -> { + logWindow = new LogWindow(process, logs); logWindow.show(); - latch.countDown(); + logWindowLatch.countDown(); }); + + logBuffer = new LinkedBlockingQueue<>(); + submitLogThread = Lang.thread(new Runnable() { + private final ArrayList currentLogs = new ArrayList<>(); + private final Semaphore semaphore = new Semaphore(0); + + private void submitLogs() { + if (currentLogs.size() == 1) { + Log log = currentLogs.get(0); + runLater(() -> logWindow.logLine(log)); + } else { + runLater(() -> { + logWindow.logLines(currentLogs); + semaphore.release(); + }); + semaphore.acquireUninterruptibly(); + } + currentLogs.clear(); + } + + @Override + public void run() { + while (true) { + try { + currentLogs.add(logBuffer.take()); + //noinspection BusyWait + Thread.sleep(200); // Wait for more logs + } catch (InterruptedException e) { + break; + } + + logBuffer.drainTo(currentLogs); + submitLogs(); + } + + do { + submitLogs(); + } while (logBuffer.drainTo(currentLogs) > 0); + } + }, "Game Log Submitter", true); + + try { + logWindowLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } } private void finishLaunch() { switch (launcherVisibility) { case HIDE_AND_REOPEN: - Platform.runLater(() -> { + runLater(() -> { // If application was stopped and execution services did not finish termination, // these codes will be executed. if (Controllers.getStage() != null) { Controllers.getStage().hide(); - emitStatus(LoadingState.DONE); + launchingLatch.countDown(); } }); break; @@ -498,17 +814,18 @@ private void finishLaunch() { // Never come to here. break; case KEEP: - Platform.runLater(() -> { - emitStatus(LoadingState.DONE); - }); + runLater(launchingLatch::countDown); break; case HIDE: - Platform.runLater(() -> { + launchingLatch.countDown(); + runLater(() -> { // If application was stopped and execution services did not finish termination, // these codes will be executed. if (Controllers.getStage() != null) { Controllers.getStage().close(); - emitStatus(LoadingState.DONE); + Controllers.shutdown(); + Schedulers.shutdown(); + System.gc(); } }); break; @@ -516,75 +833,81 @@ private void finishLaunch() { } @Override - public synchronized void onLog(String log, Log4jLevel level) { - String newLog = log; - for (Map.Entry entry : forbiddenTokens.entrySet()) - newLog = newLog.replace(entry.getKey(), entry.getValue()); - - if (level.lessOrEqual(Log4jLevel.ERROR)) - System.err.print(log); + public void onLog(String log, boolean isErrorStream) { + if (isErrorStream) + System.err.println(log); else - System.out.print(log); + System.out.println(log); - logs.add(pair(log, level)); - if (logs.size() > config().getLogLines()) - logs.removeFirst(); + log = StringUtils.parseEscapeSequence(log); + if (forbiddenAccessToken != null) + log = log.replace(forbiddenAccessToken, ""); + Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null; if (showLogs) { - try { - latch.await(); - logWindow.waitForLoaded(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; + if (level == null) + level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO); + logBuffer.add(new Log(log, level)); + } else { + synchronized (this) { + logs.addLast(new Log(log, level)); + if (logs.size() > Log.getLogLines()) + logs.removeFirst(); } - - Platform.runLater(() -> logWindow.logLine(log, level)); } - if (!lwjgl && (log.contains("LWJGL Version: ") || !detectWindow)) { - lwjgl = true; - finishLaunch(); + if (!lwjgl) { + String lowerCaseLog = log.toLowerCase(Locale.ROOT); + if (!detectWindow || lowerCaseLog.contains("lwjgl version") || lowerCaseLog.contains("lwjgl openal")) { + synchronized (this) { + if (!lwjgl) { + lwjgl = true; + finishLaunch(); + } + } + } } } @Override public void onExit(int exitCode, ExitType exitType) { + if (showLogs) { + logBuffer.add(new Log(String.format("[HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", exitCode, exitCode, exitType), Log4jLevel.INFO)); + submitLogThread.interrupt(); + try { + submitLogThread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + launchingLatch.countDown(); + if (exitType == ExitType.INTERRUPTED) return; // Game crashed before opening the game window. - if (!lwjgl) finishLaunch(); - - if (exitType != ExitType.NORMAL && logWindow == null) - Platform.runLater(() -> { - logWindow = new LogWindow(); - - switch (exitType) { - case JVM_ERROR: - logWindow.setTitle(i18n("launch.failed.cannot_create_jvm")); - break; - case APPLICATION_ERROR: - logWindow.setTitle(i18n("launch.failed.exited_abnormally")); - break; - } + if (!lwjgl) { + synchronized (this) { + if (!lwjgl) + finishLaunch(); + } + } - logWindow.show(); - logWindow.onDone.register(() -> { - logWindow.logLine("Command: " + new CommandBuilder().addAll(process.getCommands()).toString(), Log4jLevel.INFO); - for (Map.Entry entry : logs) - logWindow.logLine(entry.getKey(), entry.getValue()); - }); - }); + if (exitType != ExitType.NORMAL) { + repository.markVersionLaunchedAbnormally(version.getId()); + runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, logs).show()); + } checkExit(); } } - public static final Queue PROCESSES = new ConcurrentLinkedQueue<>(); + public static final Queue> PROCESSES = new ConcurrentLinkedQueue<>(); + public static void stopManagedProcesses() { while (!PROCESSES.isEmpty()) - Optional.ofNullable(PROCESSES.poll()).ifPresent(ManagedProcess::stop); + Optional.ofNullable(PROCESSES.poll()).map(WeakReference::get).ifPresent(ManagedProcess::stop); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java new file mode 100644 index 0000000000..8a15898173 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java @@ -0,0 +1,131 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2022 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.mod.LocalModFile; +import org.jackhuang.hmcl.mod.RemoteMod; +import org.jackhuang.hmcl.mod.RemoteModRepository; +import org.jackhuang.hmcl.ui.versions.ModTranslations; +import org.jackhuang.hmcl.util.Pair; +import org.jackhuang.hmcl.util.StringUtils; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Stream; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public abstract class LocalizedRemoteModRepository implements RemoteModRepository { + private static final int CONTAIN_CHINESE_WEIGHT = 10; + + private static final int INITIAL_CAPACITY = 16; + + protected abstract RemoteModRepository getBackedRemoteModRepository(); + + protected abstract SortType getBackedRemoteModRepositorySortOrder(); + + @Override + public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException { + if (!StringUtils.containsChinese(searchFilter)) { + return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder); + } + + Set englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY); + + int count = 0; + for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) { + for (String englishWord : StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName())) { + if (englishSearchFiltersSet.contains(englishWord)) { + continue; + } + + englishSearchFiltersSet.add(englishWord); + } + + count++; + if (count >= 3) break; + } + + RemoteMod[] searchResultArray = new RemoteMod[pageSize]; + int totalPages, chineseIndex = 0, englishIndex = pageSize - 1; + { + SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder); + for (Iterator iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) { + if (chineseIndex > englishIndex) { + LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?"); + continue; + } + + RemoteMod remoteMod = iterator.next(); + ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug()); + if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) { + searchResultArray[chineseIndex++] = remoteMod; + } else { + searchResultArray[englishIndex--] = remoteMod; + } + } + totalPages = searchResult.getTotalPages(); + } + + StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator(); + return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> { + ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug()); + if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) { + return Pair.pair(remoteMod, Integer.MAX_VALUE); + } + String chineseRemoteModName = chineseRemoteMod.getName(); + if (searchFilter.isEmpty() || chineseRemoteModName.isEmpty()) { + return Pair.pair(remoteMod, Math.max(searchFilter.length(), chineseRemoteModName.length())); + } + int l = levCalculator.calc(searchFilter, chineseRemoteModName); + for (int i = 0; i < searchFilter.length(); i++) { + if (chineseRemoteModName.indexOf(searchFilter.charAt(i)) >= 0) { + l -= CONTAIN_CHINESE_WEIGHT; + } + } + return Pair.pair(remoteMod, l); + }).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), Arrays.stream(searchResultArray, englishIndex + 1, searchResultArray.length)), totalPages); + } + + @Override + public Stream getCategories() throws IOException { + return getBackedRemoteModRepository().getCategories(); + } + + @Override + public Optional getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException { + return getBackedRemoteModRepository().getRemoteVersionByLocalFile(localModFile, file); + } + + @Override + public RemoteMod getModById(String id) throws IOException { + return getBackedRemoteModRepository().getModById(id); + } + + @Override + public RemoteMod.File getModFile(String modId, String fileId) throws IOException { + return getBackedRemoteModRepository().getModFile(modId, fileId); + } + + @Override + public Stream getRemoteVersionsById(String id) throws IOException { + return getBackedRemoteModRepository().getRemoteVersionsById(id); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/Log.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/Log.java new file mode 100644 index 0000000000..ea01aee3ec --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/Log.java @@ -0,0 +1,72 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jackhuang.hmcl.util.Log4jLevel; + +import static org.jackhuang.hmcl.setting.ConfigHolder.config; + +public final class Log { + public static final int DEFAULT_LOG_LINES = 2000; + + public static int getLogLines() { + Integer lines = config().getLogLines(); + return lines != null && lines > 0 ? lines : DEFAULT_LOG_LINES; + } + + private final String log; + private Log4jLevel level; + private boolean selected = false; + + public Log(String log) { + this.log = log; + } + + public Log(String log, Log4jLevel level) { + this.log = log; + this.level = level; + } + + public String getLog() { + return log; + } + + public Log4jLevel getLevel() { + Log4jLevel level = this.level; + if (level == null) { + level = Log4jLevel.guessLevel(log); + if (level == null) + level = Log4jLevel.INFO; + this.level = level; + } + return level; + } + + public boolean isSelected() { + return selected; + } + + public void setSelected(boolean selected) { + this.selected = selected; + } + + @Override + public String toString() { + return log; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java new file mode 100644 index 0000000000..3cb59219e4 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java @@ -0,0 +1,109 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jackhuang.hmcl.util.io.IOUtils; +import org.jackhuang.hmcl.util.logging.Logger; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.io.Zipper; +import org.jackhuang.hmcl.util.platform.OperatingSystem; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.management.ManagementFactory; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class LogExporter { + private LogExporter() { + } + + public static CompletableFuture exportLogs(Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript) { + Path runDirectory = gameRepository.getRunDirectory(versionId); + Path baseDirectory = gameRepository.getBaseDirectory(); + List versions = new ArrayList<>(); + + String currentVersionId = versionId; + HashSet resolvedSoFar = new HashSet<>(); + while (true) { + if (resolvedSoFar.contains(currentVersionId)) break; + resolvedSoFar.add(currentVersionId); + Version currentVersion = gameRepository.getVersion(currentVersionId); + versions.add(currentVersionId); + + if (StringUtils.isNotBlank(currentVersion.getInheritsFrom())) { + currentVersionId = currentVersion.getInheritsFrom(); + } else { + break; + } + } + + return CompletableFuture.runAsync(() -> { + try (Zipper zipper = new Zipper(zipFile)) { + processLogs(runDirectory.resolve("liteconfig"), "*.log", "liteconfig", zipper); + processLogs(runDirectory.resolve("logs"), "*.log", "logs", zipper); + processLogs(runDirectory, "*.log", "runDirectory", zipper); + processLogs(runDirectory.resolve("crash-reports"), "*.txt", "crash-reports", zipper); + + zipper.putTextFile(LOG.getLogs(), "hmcl.log"); + zipper.putTextFile(logs, "minecraft.log"); + zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh"); + + for (String id : versions) { + Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json"); + if (Files.exists(versionJson)) { + zipper.putFile(versionJson, id + ".json"); + } + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + + private static void processLogs(Path directory, String fileExtension, String logDirectory, Zipper zipper) { + try (DirectoryStream stream = Files.newDirectoryStream(directory, fileExtension)) { + long processStartTime = ManagementFactory.getRuntimeMXBean().getStartTime(); + + for (Path file : stream) { + if (Files.isRegularFile(file)) { + FileTime time = Files.readAttributes(file, BasicFileAttributes.class).lastModifiedTime(); + if (time.toMillis() >= processStartTime) { + try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(file)) { + zipper.putLines(reader.lines().map(Logger::filterForbiddenToken), file.getFileName().toString()); + } catch (IOException e) { + LOG.warning("Failed to read log file: " + file, e); + } + } + } + } + } catch (Throwable e) { + LOG.warning("Failed to find any log on " + logDirectory, e); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/ManuallyCreatedModpackException.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/ManuallyCreatedModpackException.java new file mode 100644 index 0000000000..12a328cc65 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ManuallyCreatedModpackException.java @@ -0,0 +1,32 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import java.nio.file.Path; + +public class ManuallyCreatedModpackException extends Exception { + private final Path path; + + public ManuallyCreatedModpackException(Path path) { + this.path = path; + } + + public Path getPath() { + return path; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/ManuallyCreatedModpackInstallTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/ManuallyCreatedModpackInstallTask.java new file mode 100644 index 0000000000..281d26bad3 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ManuallyCreatedModpackInstallTask.java @@ -0,0 +1,61 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jackhuang.hmcl.util.io.Unzipper; + +import java.nio.charset.Charset; +import java.nio.file.FileSystem; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class ManuallyCreatedModpackInstallTask extends Task { + + private final Profile profile; + private final Path zipFile; + private final Charset charset; + private final String name; + + public ManuallyCreatedModpackInstallTask(Profile profile, Path zipFile, Charset charset, String name) { + this.profile = profile; + this.zipFile = zipFile; + this.charset = charset; + this.name = name; + } + + @Override + public void execute() throws Exception { + Path subdirectory; + try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(charset).build()) { + subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), fs); + } + + Path dest = Paths.get("externalgames").resolve(name); + + setResult(dest); + + new Unzipper(zipFile, dest) + .setSubDirectory(subdirectory.toString()) + .setTerminateIfSubDirectoryNotExists() + .setEncoding(charset) + .unzip(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java index eb7be914d2..019d8bc4fa 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,10 +18,21 @@ package org.jackhuang.hmcl.game; import com.google.gson.JsonParseException; -import com.google.gson.reflect.TypeToken; +import kala.compress.archivers.zip.ZipArchiveReader; +import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.mod.*; -import org.jackhuang.hmcl.setting.EnumGameDirectory; +import org.jackhuang.hmcl.mod.curse.CurseModpackProvider; +import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackManifest; +import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackProvider; +import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackProvider; +import org.jackhuang.hmcl.mod.multimc.MultiMCComponents; +import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration; +import org.jackhuang.hmcl.mod.multimc.MultiMCModpackProvider; +import org.jackhuang.hmcl.mod.server.ServerModpackManifest; +import org.jackhuang.hmcl.mod.server.ServerModpackProvider; +import org.jackhuang.hmcl.mod.server.ServerModpackRemoteInstallTask; import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.setting.Profiles; import org.jackhuang.hmcl.setting.VersionSetting; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -29,64 +40,113 @@ import org.jackhuang.hmcl.util.function.ExceptionalConsumer; import org.jackhuang.hmcl.util.function.ExceptionalRunnable; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.Nullable; -import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.charset.Charset; +import java.nio.file.FileSystem; +import java.nio.file.Files; import java.nio.file.Path; -import java.util.Optional; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Stream; + +import static org.jackhuang.hmcl.util.Lang.mapOf; +import static org.jackhuang.hmcl.util.Lang.toIterable; +import static org.jackhuang.hmcl.util.Pair.pair; public final class ModpackHelper { - private ModpackHelper() {} + private ModpackHelper() { + } - public static Modpack readModpackManifest(Path file, Charset charset) throws UnsupportedModpackException { - try { - return CurseManifest.readCurseForgeModpackManifest(file, charset); - } catch (Exception e) { - // ignore it, not a valid CurseForge modpack. - } + private static final Map providers = mapOf( + pair(CurseModpackProvider.INSTANCE.getName(), CurseModpackProvider.INSTANCE), + pair(McbbsModpackProvider.INSTANCE.getName(), McbbsModpackProvider.INSTANCE), + pair(ModrinthModpackProvider.INSTANCE.getName(), ModrinthModpackProvider.INSTANCE), + pair(MultiMCModpackProvider.INSTANCE.getName(), MultiMCModpackProvider.INSTANCE), + pair(ServerModpackProvider.INSTANCE.getName(), ServerModpackProvider.INSTANCE), + pair(HMCLModpackProvider.INSTANCE.getName(), HMCLModpackProvider.INSTANCE) + ); - try { - return HMCLModpackManager.readHMCLModpackManifest(file, charset); - } catch (Exception e) { - // ignore it, not a valid HMCL modpack. + static { + MultiMCComponents.setImplementation(Metadata.FULL_TITLE); + } + + @Nullable + public static ModpackProvider getProviderByType(String type) { + return providers.get(type); + } + + public static boolean isFileModpackByExtension(Path file) { + String ext = FileUtils.getExtension(file); + return "zip".equals(ext) || "mrpack".equals(ext); + } + + public static Modpack readModpackManifest(Path file, Charset charset) throws UnsupportedModpackException, ManuallyCreatedModpackException { + try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file, charset)) { + // Order for trying detecting manifest is necessary here. + // Do not change to iterating providers. + for (ModpackProvider provider : new ModpackProvider[]{ + McbbsModpackProvider.INSTANCE, + CurseModpackProvider.INSTANCE, + ModrinthModpackProvider.INSTANCE, + HMCLModpackProvider.INSTANCE, + MultiMCModpackProvider.INSTANCE, + ServerModpackProvider.INSTANCE}) { + try { + return provider.readManifest(zipFile, file, charset); + } catch (Exception ignored) { + } + } + } catch (IOException ignored) { } - try { - return MultiMCInstanceConfiguration.readMultiMCModpackManifest(file, charset); - } catch (Exception e) { - // ignore it, not a valid MultiMC modpack. + try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file, charset)) { + findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), fs); + throw new ManuallyCreatedModpackException(file); + } catch (IOException e) { + // ignore it } throw new UnsupportedModpackException(file.toString()); } - public static ModpackConfiguration readModpackConfiguration(File file) throws IOException { - if (!file.exists()) - throw new FileNotFoundException(file.getPath()); - else - try { - return JsonUtils.GSON.fromJson(FileUtils.readText(file), new TypeToken>() { - }.getType()); - } catch (JsonParseException e) { - throw new IOException("Malformed modpack configuration"); + public static Path findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, FileSystem fs) throws IOException, UnsupportedModpackException { + Path root = fs.getPath("/"); + if (isMinecraftDirectory(root)) return root; + try (Stream firstLayer = Files.list(root)) { + for (Path dir : toIterable(firstLayer)) { + if (isMinecraftDirectory(dir)) return dir; + + try (Stream secondLayer = Files.list(dir)) { + for (Path subdir : toIterable(secondLayer)) { + if (isMinecraftDirectory(subdir)) return subdir; + } + } catch (IOException ignored) { + } } + } catch (IOException ignored) { + } + throw new UnsupportedModpackException(modpackName); } - private static String getManifestType(Object manifest) throws UnsupportedModpackException { - if (manifest instanceof HMCLModpackManifest) - return HMCLModpackInstallTask.MODPACK_TYPE; - else if (manifest instanceof MultiMCInstanceConfiguration) - return MultiMCModpackInstallTask.MODPACK_TYPE; - else if (manifest instanceof CurseManifest) - return CurseInstallTask.MODPACK_TYPE; - else - throw new UnsupportedModpackException(); + private static boolean isMinecraftDirectory(Path path) { + return Files.isDirectory(path.resolve("versions")) && + (path.getFileName() == null || ".minecraft".equals(FileUtils.getName(path))); + } + + public static ModpackConfiguration readModpackConfiguration(Path file) throws IOException { + try { + return JsonUtils.fromJsonFile(file, ModpackConfiguration.class); + } catch (JsonParseException e) { + throw new IOException("Malformed modpack configuration"); + } } - public static Task getInstallTask(Profile profile, File zipFile, String name, Modpack modpack) { + public static Task getInstallTask(Profile profile, ServerModpackManifest manifest, String name, Modpack modpack) { profile.getRepository().markVersionAsModpack(name); ExceptionalRunnable success = () -> { @@ -95,59 +155,100 @@ public static Task getInstallTask(Profile profile, File zipFile, String name, Mo VersionSetting vs = repository.specializeVersionSetting(name); repository.undoMark(name); if (vs != null) - vs.setGameDirType(EnumGameDirectory.VERSION_FOLDER); + vs.setGameDirType(GameDirectoryType.VERSION_FOLDER); }; ExceptionalConsumer failure = ex -> { - if (ex instanceof CurseCompletionException && !(ex.getCause() instanceof FileNotFoundException)) { + if (ex instanceof ModpackCompletionException && !(ex.getCause() instanceof FileNotFoundException)) { success.run(); // This is tolerable and we will not delete the game - } else { - HMCLGameRepository repository = profile.getRepository(); - repository.removeVersionFromDisk(name); } }; - if (modpack.getManifest() instanceof CurseManifest) - return new CurseInstallTask(profile.getDependency(), zipFile, modpack, ((CurseManifest) modpack.getManifest()), name) - .finalized(Schedulers.defaultScheduler(), ExceptionalConsumer.fromRunnable(success), failure); - else if (modpack.getManifest() instanceof HMCLModpackManifest) - return new HMCLModpackInstallTask(profile, zipFile, modpack, name) - .finalized(Schedulers.defaultScheduler(), ExceptionalConsumer.fromRunnable(success), failure); - else if (modpack.getManifest() instanceof MultiMCInstanceConfiguration) - return new MultiMCModpackInstallTask(profile.getDependency(), zipFile, modpack, ((MultiMCInstanceConfiguration) modpack.getManifest()), name) - .finalized(Schedulers.defaultScheduler(), ExceptionalConsumer.fromRunnable(success), failure) - .then(new MultiMCInstallVersionSettingTask(profile, ((MultiMCInstanceConfiguration) modpack.getManifest()), name)); - else throw new IllegalStateException("Unrecognized modpack: " + modpack); + return new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name) + .whenComplete(Schedulers.defaultScheduler(), success, failure) + .withStagesHint(Arrays.asList("hmcl.modpack", "hmcl.modpack.download")); } - public static Task getUpdateTask(Profile profile, File zipFile, Charset charset, String name, ModpackConfiguration configuration) throws UnsupportedModpackException, MismatchedModpackTypeException { - Modpack modpack = ModpackHelper.readModpackManifest(zipFile.toPath(), charset); + public static boolean isExternalGameNameConflicts(String name) { + return Files.exists(Paths.get("externalgames").resolve(name)); + } - switch (configuration.getType()) { - case CurseInstallTask.MODPACK_TYPE: - if (!(modpack.getManifest() instanceof CurseManifest)) - throw new MismatchedModpackTypeException(CurseInstallTask.MODPACK_TYPE, getManifestType(modpack.getManifest())); + public static Task getInstallManuallyCreatedModpackTask(Profile profile, Path zipFile, String name, Charset charset) { + if (isExternalGameNameConflicts(name)) { + throw new IllegalArgumentException("name existing"); + } + + return new ManuallyCreatedModpackInstallTask(profile, zipFile, charset, name) + .thenAcceptAsync(Schedulers.javafx(), location -> { + Profile newProfile = new Profile(name, location); + newProfile.setUseRelativePath(true); + Profiles.getProfiles().add(newProfile); + Profiles.setSelectedProfile(newProfile); + }); + } + + public static Task getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack) { + profile.getRepository().markVersionAsModpack(name); + + ExceptionalRunnable success = () -> { + HMCLGameRepository repository = profile.getRepository(); + repository.refreshVersions(); + VersionSetting vs = repository.specializeVersionSetting(name); + repository.undoMark(name); + if (vs != null) + vs.setGameDirType(GameDirectoryType.VERSION_FOLDER); + }; - return new ModpackUpdateTask(profile.getRepository(), name, new CurseInstallTask(profile.getDependency(), zipFile, modpack, (CurseManifest) modpack.getManifest(), name)); - case MultiMCModpackInstallTask.MODPACK_TYPE: - if (!(modpack.getManifest() instanceof MultiMCInstanceConfiguration)) - throw new MismatchedModpackTypeException(MultiMCModpackInstallTask.MODPACK_TYPE, getManifestType(modpack.getManifest())); + ExceptionalConsumer failure = ex -> { + if (ex instanceof ModpackCompletionException && !(ex.getCause() instanceof FileNotFoundException)) { + success.run(); + // This is tolerable and we will not delete the game + } + }; - return new ModpackUpdateTask(profile.getRepository(), name, new MultiMCModpackInstallTask(profile.getDependency(), zipFile, modpack, (MultiMCInstanceConfiguration) modpack.getManifest(), name)); - case HMCLModpackInstallTask.MODPACK_TYPE: - if (!(modpack.getManifest() instanceof HMCLModpackManifest)) - throw new MismatchedModpackTypeException(HMCLModpackInstallTask.MODPACK_TYPE, getManifestType(modpack.getManifest())); + if (modpack.getManifest() instanceof MultiMCInstanceConfiguration) + return modpack.getInstallTask(profile.getDependency(), zipFile, name) + .whenComplete(Schedulers.defaultScheduler(), success, failure) + .thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name)) + .withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download")); + else if (modpack.getManifest() instanceof McbbsModpackManifest) + return modpack.getInstallTask(profile.getDependency(), zipFile, name) + .whenComplete(Schedulers.defaultScheduler(), success, failure) + .thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name)) + .withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download")); + else + return modpack.getInstallTask(profile.getDependency(), zipFile, name) + .whenComplete(Schedulers.javafx(), success, failure) + .withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download")); + } - return new ModpackUpdateTask(profile.getRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name)); + public static Task getUpdateTask(Profile profile, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration configuration) throws UnsupportedModpackException { + switch (configuration.getType()) { + case ServerModpackRemoteInstallTask.MODPACK_TYPE: + return new ModpackUpdateTask(profile.getRepository(), name, new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name)) + .withStagesHint(Arrays.asList("hmcl.modpack", "hmcl.modpack.download")); default: throw new UnsupportedModpackException(); } } + public static Task getUpdateTask(Profile profile, Path zipFile, Charset charset, String name, ModpackConfiguration configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException { + Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset); + ModpackProvider provider = getProviderByType(configuration.getType()); + if (provider == null) { + throw new UnsupportedModpackException(); + } + if (modpack.getManifest() instanceof MultiMCInstanceConfiguration) + return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack) + .thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name)); + else + return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack); + } + public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetting vs) { vs.setUsesGlobal(false); - vs.setGameDirType(EnumGameDirectory.VERSION_FOLDER); + vs.setGameDirType(GameDirectoryType.VERSION_FOLDER); if (c.isOverrideJavaLocation()) { vs.setJavaDir(Lang.nonNull(c.getJavaPath(), "")); @@ -182,5 +283,36 @@ public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetti } } + private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, VersionSetting vs) { + if (c.isOverrideCommands()) { + vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), "")); + vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), "")); + } + + if (c.isOverrideJavaArgs()) { + vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), "")); + } + } + + private static Task createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) { + return Task.runAsync(Schedulers.javafx(), () -> { + VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version)); + ModpackHelper.applyCommandAndJvmSettings(manifest, vs); + }); + } + + private static Task createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) { + return Task.runAsync(Schedulers.javafx(), () -> { + VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version)); + ModpackHelper.toVersionSetting(manifest, vs); + }); + } + private static Task createMcbbsPostInstallTask(Profile profile, McbbsModpackManifest manifest, String version) { + return Task.runAsync(Schedulers.javafx(), () -> { + VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version)); + if (manifest.getLaunchInfo().getMinMemory() > vs.getMaxMemory()) + vs.setMaxMemory(manifest.getLaunchInfo().getMinMemory()); + }); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/MultiMCInstallVersionSettingTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/MultiMCInstallVersionSettingTask.java deleted file mode 100644 index e4d6bd6fb7..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/MultiMCInstallVersionSettingTask.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.game; - -import org.jackhuang.hmcl.mod.MultiMCInstanceConfiguration; -import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.setting.VersionSetting; -import org.jackhuang.hmcl.task.Scheduler; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; - -import java.util.Objects; - -public final class MultiMCInstallVersionSettingTask extends Task { - private final Profile profile; - private final MultiMCInstanceConfiguration manifest; - private final String version; - - public MultiMCInstallVersionSettingTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) { - this.profile = profile; - this.manifest = manifest; - this.version = version; - } - - @Override - public Scheduler getScheduler() { - return Schedulers.javafx(); - } - - @Override - public void execute() { - VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version)); - ModpackHelper.toVersionSetting(manifest, vs); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java new file mode 100644 index 0000000000..1513bfca2d --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java @@ -0,0 +1,212 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import fi.iki.elonen.NanoHTTPD; +import org.jackhuang.hmcl.auth.AuthenticationException; +import org.jackhuang.hmcl.auth.OAuth; +import org.jackhuang.hmcl.event.Event; +import org.jackhuang.hmcl.event.EventManager; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.io.IOUtils; +import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.io.NetworkUtils; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import static org.jackhuang.hmcl.util.Lang.mapOf; +import static org.jackhuang.hmcl.util.Lang.thread; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public final class OAuthServer extends NanoHTTPD implements OAuth.Session { + private final int port; + private final CompletableFuture future = new CompletableFuture<>(); + + public static String lastlyOpenedURL; + + private String idToken; + + private OAuthServer(int port) { + super(port); + + this.port = port; + } + + @Override + public String getRedirectURI() { + return String.format("http://localhost:%d/auth-response", port); + } + + @Override + public String waitFor() throws InterruptedException, ExecutionException { + return future.get(); + } + + @Override + public String getIdToken() { + return idToken; + } + + @Override + public Response serve(IHTTPSession session) { + if (!"/auth-response".equals(session.getUri())) { + return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, ""); + } + + if (session.getMethod() == Method.POST) { + Map files = new HashMap<>(); + try { + session.parseBody(files); + } catch (IOException e) { + LOG.warning("Failed to read post data", e); + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_HTML, ""); + } catch (ResponseException re) { + return newFixedLengthResponse(re.getStatus(), MIME_PLAINTEXT, re.getMessage()); + } + } else if (session.getMethod() == Method.GET) { + // do nothing + } else { + return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, ""); + } + String parameters = session.getQueryParameterString(); + + Map query = mapOf(NetworkUtils.parseQuery(parameters)); + if (query.containsKey("code")) { + idToken = query.get("id_token"); + future.complete(query.get("code")); + } else { + LOG.warning("Error: " + parameters); + future.completeExceptionally(new AuthenticationException("failed to authenticate")); + } + + String html; + try { + html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html")) + .replace("%lang%", Locale.getDefault().toLanguageTag()) + .replace("%close-page%", i18n("account.methods.microsoft.close_page")); + } catch (IOException e) { + LOG.error("Failed to load html", e); + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_HTML, ""); + } + thread(() -> { + try { + Thread.sleep(1000); + stop(); + } catch (InterruptedException e) { + LOG.error("Failed to sleep for 1 second"); + } + }); + return newFixedLengthResponse(Response.Status.OK, "text/html; charset=UTF-8", html); + } + + public static class Factory implements OAuth.Callback { + public final EventManager onGrantDeviceCode = new EventManager<>(); + public final EventManager onOpenBrowser = new EventManager<>(); + + @Override + public OAuth.Session startServer() throws IOException, AuthenticationException { + if (StringUtils.isBlank(getClientId())) { + throw new MicrosoftAuthenticationNotSupportedException(); + } + + IOException exception = null; + for (int port : new int[]{29111, 29112, 29113, 29114, 29115}) { + try { + OAuthServer server = new OAuthServer(port); + server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, true); + return server; + } catch (IOException e) { + exception = e; + } + } + throw exception; + } + + @Override + public void grantDeviceCode(String userCode, String verificationURI) { + onGrantDeviceCode.fireEvent(new GrantDeviceCodeEvent(this, userCode, verificationURI)); + } + + @Override + public void openBrowser(String url) throws IOException { + lastlyOpenedURL = url; + FXUtils.openLink(url); + + onOpenBrowser.fireEvent(new OpenBrowserEvent(this, url)); + } + + @Override + public String getClientId() { + return System.getProperty("hmcl.microsoft.auth.id", + JarUtils.getAttribute("hmcl.microsoft.auth.id", "")); + } + + @Override + public String getClientSecret() { + return System.getProperty("hmcl.microsoft.auth.secret", + JarUtils.getAttribute("hmcl.microsoft.auth.secret", "")); + } + + @Override + public boolean isPublicClient() { + return true; // We have turned on the device auth flow. + } + } + + public static class GrantDeviceCodeEvent extends Event { + private final String userCode; + private final String verificationUri; + + public GrantDeviceCodeEvent(Object source, String userCode, String verificationUri) { + super(source); + this.userCode = userCode; + this.verificationUri = verificationUri; + } + + public String getUserCode() { + return userCode; + } + + public String getVerificationUri() { + return verificationUri; + } + } + + public static class OpenBrowserEvent extends Event { + private final String url; + + public OpenBrowserEvent(Object source, String url) { + super(source); + this.url = url; + } + + public String getUrl() { + return url; + } + } + + public static class MicrosoftAuthenticationNotSupportedException extends AuthenticationException { + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java index f1d967a400..ab0c15dd72 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,43 +17,44 @@ */ package org.jackhuang.hmcl.game; -import static java.util.Collections.singletonMap; -import static org.jackhuang.hmcl.util.Lang.threadPool; -import static org.jackhuang.hmcl.util.Logging.LOG; +import javafx.beans.InvalidationListener; +import javafx.beans.WeakInvalidationListener; +import javafx.beans.binding.ObjectBinding; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.image.Image; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.auth.Account; +import org.jackhuang.hmcl.auth.ServerResponseMalformedException; +import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; +import org.jackhuang.hmcl.auth.offline.OfflineAccount; +import org.jackhuang.hmcl.auth.offline.Skin; +import org.jackhuang.hmcl.auth.yggdrasil.*; +import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.Holder; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.javafx.BindingMapping; -import java.awt.Graphics2D; -import java.awt.image.BufferedImage; import java.io.IOException; import java.io.InputStream; -import java.io.UncheckedIOException; -import java.net.URL; +import java.lang.ref.WeakReference; import java.nio.file.Files; import java.nio.file.Path; -import java.util.EnumMap; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; +import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import java.util.logging.Level; - -import javax.imageio.ImageIO; -import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.auth.ServerResponseMalformedException; -import org.jackhuang.hmcl.auth.yggdrasil.Texture; -import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; -import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; -import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; -import org.jackhuang.hmcl.task.FileDownloadTask; -import org.jackhuang.hmcl.util.javafx.MultiStepBinding; - -import javafx.beans.binding.Bindings; -import javafx.beans.binding.ObjectBinding; -import javafx.embed.swing.SwingFXUtils; -import javafx.scene.image.Image; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonMap; +import static java.util.Objects.requireNonNull; +import static org.jackhuang.hmcl.util.Lang.threadPool; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author yushijinhun @@ -65,15 +66,15 @@ private TexturesLoader() { // ==== Texture Loading ==== public static class LoadedTexture { - private final BufferedImage image; + private final Image image; private final Map metadata; - public LoadedTexture(BufferedImage image, Map metadata) { - this.image = image; - this.metadata = metadata; + public LoadedTexture(Image image, Map metadata) { + this.image = requireNonNull(image); + this.metadata = requireNonNull(metadata); } - public BufferedImage getImage() { + public Image getImage() { return image; } @@ -83,7 +84,7 @@ public Map getMetadata() { } private static final ThreadPoolExecutor POOL = threadPool("TexturesDownload", true, 2, 10, TimeUnit.SECONDS); - private static final Path TEXTURES_DIR = Metadata.MINECRAFT_DIRECTORY.resolve("assets").resolve("skins"); + private static final Path TEXTURES_DIR = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("skins"); private static Path getTexturePath(Texture texture) { String url = texture.getUrl(); @@ -97,112 +98,235 @@ private static Path getTexturePath(Texture texture) { return TEXTURES_DIR.resolve(prefix).resolve(hash); } - public static LoadedTexture loadTexture(Texture texture) throws IOException { + public static LoadedTexture loadTexture(Texture texture) throws Throwable { + if (StringUtils.isBlank(texture.getUrl())) { + throw new IOException("Texture url is empty"); + } + Path file = getTexturePath(texture); if (!Files.isRegularFile(file)) { // download it try { - new FileDownloadTask(new URL(texture.getUrl()), file.toFile()).run(); + new FileDownloadTask(texture.getUrl(), file).run(); LOG.info("Texture downloaded: " + texture.getUrl()); } catch (Exception e) { if (Files.isRegularFile(file)) { // concurrency conflict? - LOG.log(Level.WARNING, "Failed to download texture " + texture.getUrl() + ", but the file is available", e); + LOG.warning("Failed to download texture " + texture.getUrl() + ", but the file is available", e); } else { throw new IOException("Failed to download texture " + texture.getUrl()); } } } - BufferedImage img; + Image img; try (InputStream in = Files.newInputStream(file)) { - img = ImageIO.read(in); + img = new Image(in); + } + + if (img.isError()) + throw img.getException(); + + Map metadata = texture.getMetadata(); + if (metadata == null) { + metadata = emptyMap(); } - return new LoadedTexture(img, texture.getMetadata()); + return new LoadedTexture(img, metadata); } // ==== // ==== Skins ==== - private final static Map DEFAULT_SKINS = new EnumMap<>(TextureModel.class); - static { - loadDefaultSkin("/assets/img/steve.png", TextureModel.STEVE); - loadDefaultSkin("/assets/img/alex.png", TextureModel.ALEX); - } - private static void loadDefaultSkin(String path, TextureModel model) { - try (InputStream in = TexturesLoader.class.getResourceAsStream(path)) { - DEFAULT_SKINS.put(model, new LoadedTexture(ImageIO.read(in), singletonMap("model", model.modelName))); - } catch (IOException e) { - throw new UncheckedIOException(e); + private static final String[] DEFAULT_SKINS = {"alex", "ari", "efe", "kai", "makena", "noor", "steve", "sunny", "zuri"}; + + public static Image getDefaultSkinImage() { + return FXUtils.newBuiltinImage("/assets/img/skin/wide/steve.png"); + } + + public static LoadedTexture getDefaultSkin(UUID uuid) { + int idx = Math.floorMod(uuid.hashCode(), DEFAULT_SKINS.length * 2); + TextureModel model; + Image skin; + if (idx < DEFAULT_SKINS.length) { + model = TextureModel.SLIM; + skin = FXUtils.newBuiltinImage("/assets/img/skin/slim/" + DEFAULT_SKINS[idx] + ".png"); + } else { + model = TextureModel.WIDE; + skin = FXUtils.newBuiltinImage("/assets/img/skin/wide/" + DEFAULT_SKINS[idx - DEFAULT_SKINS.length] + ".png"); } + + return new LoadedTexture(skin, singletonMap("model", model.modelName)); } - public static LoadedTexture getDefaultSkin(TextureModel model) { - return DEFAULT_SKINS.get(model); + public static TextureModel getDefaultModel(UUID uuid) { + return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).getMetadata().get("model")) + ? TextureModel.WIDE + : TextureModel.SLIM; } public static ObjectBinding skinBinding(YggdrasilService service, UUID uuid) { - LoadedTexture uuidFallback = getDefaultSkin(TextureModel.detectUUID(uuid)); - return MultiStepBinding.of(service.getProfileRepository().binding(uuid)) + LoadedTexture uuidFallback = getDefaultSkin(uuid); + return BindingMapping.of(service.getProfileRepository().binding(uuid)) .map(profile -> profile .flatMap(it -> { try { return YggdrasilService.getTextures(it); } catch (ServerResponseMalformedException e) { - LOG.log(Level.WARNING, "Failed to parse texture payload", e); + LOG.warning("Failed to parse texture payload", e); return Optional.empty(); } }) - .flatMap(it -> Optional.ofNullable(it.get(TextureType.SKIN)))) + .flatMap(it -> Optional.ofNullable(it.get(TextureType.SKIN))) + .filter(it -> StringUtils.isNotBlank(it.getUrl()))) .asyncMap(it -> { if (it.isPresent()) { Texture texture = it.get(); - try { - return loadTexture(texture); - } catch (IOException e) { - LOG.log(Level.WARNING, "Failed to load texture " + texture.getUrl() + ", using fallback texture", e); - return getDefaultSkin(TextureModel.detectModelName(texture.getMetadata())); - } + return CompletableFuture.supplyAsync(() -> { + try { + return loadTexture(texture); + } catch (Throwable e) { + LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e); + return uuidFallback; + } + }, POOL); } else { - return uuidFallback; + return CompletableFuture.completedFuture(uuidFallback); } - }, uuidFallback, POOL); + }, uuidFallback); + } + + public static ObservableValue skinBinding(Account account) { + LoadedTexture uuidFallback = getDefaultSkin(account.getUUID()); + if (account instanceof OfflineAccount) { + OfflineAccount offlineAccount = (OfflineAccount) account; + + SimpleObjectProperty binding = new SimpleObjectProperty<>(); + InvalidationListener listener = o -> { + Skin skin = offlineAccount.getSkin(); + String username = offlineAccount.getUsername(); + + binding.set(uuidFallback); + if (skin != null) { + skin.load(username).setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception != null) { + LOG.warning("Failed to load texture", exception); + } else if (result != null && result.getSkin() != null && result.getSkin().getImage() != null) { + Map metadata; + if (result.getModel() != null) { + metadata = singletonMap("model", result.getModel().modelName); + } else { + metadata = emptyMap(); + } + + binding.set(new LoadedTexture(result.getSkin().getImage(), metadata)); + } + }).start(); + } + }; + + listener.invalidated(offlineAccount); + + binding.addListener(new Holder<>(listener)); + offlineAccount.addListener(new WeakInvalidationListener(listener)); + + return binding; + } else { + return BindingMapping.of(account.getTextures()) + .asyncMap(textures -> { + if (textures.isPresent()) { + Texture texture = textures.get().get(TextureType.SKIN); + if (texture != null && StringUtils.isNotBlank(texture.getUrl())) { + return CompletableFuture.supplyAsync(() -> { + try { + return loadTexture(texture); + } catch (Throwable e) { + LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e); + return uuidFallback; + } + }, POOL); + } + } + + return CompletableFuture.completedFuture(uuidFallback); + }, uuidFallback); + } } + // ==== // ==== Avatar ==== - public static BufferedImage toAvatar(BufferedImage skin, int size) { - BufferedImage avatar = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); - Graphics2D g = avatar.createGraphics(); + public static void drawAvatar(Canvas canvas, Image skin) { + GraphicsContext g = canvas.getGraphicsContext2D(); + g.clearRect(0, 0, canvas.getWidth(), canvas.getHeight()); - int scale = skin.getWidth() / 64; + int size = (int) canvas.getWidth(); + int scale = (int) skin.getWidth() / 64; int faceOffset = (int) Math.round(size / 18.0); + + g.setImageSmoothing(false); + drawAvatar(g, skin, size, scale, faceOffset); + } + + private static void drawAvatar(GraphicsContext g, Image skin, int size, int scale, int faceOffset) { + g.drawImage(skin, + 8 * scale, 8 * scale, 8 * scale, 8 * scale, + faceOffset, faceOffset, size - 2 * faceOffset, size - 2 * faceOffset); g.drawImage(skin, - faceOffset, faceOffset, size - faceOffset, size - faceOffset, - 8 * scale, 8 * scale, 16 * scale, 16 * scale, - null); - - if (skin.getWidth() == skin.getHeight()) { - g.drawImage(skin, - 0, 0, size, size, - 40 * scale, 8 * scale, 48 * scale, 16 * scale, null); + 40 * scale, 8 * scale, 8 * scale, 8 * scale, + 0, 0, size, size); + } + + private static final class SkinBindingChangeListener implements ChangeListener { + static final WeakHashMap hole = new WeakHashMap<>(); + + final WeakReference canvasRef; + final ObservableValue binding; + + SkinBindingChangeListener(Canvas canvas, ObservableValue binding) { + this.canvasRef = new WeakReference<>(canvas); + this.binding = binding; + } + + @Override + public void changed(ObservableValue observable, + LoadedTexture oldValue, LoadedTexture loadedTexture) { + Canvas canvas = canvasRef.get(); + if (canvas != null) + drawAvatar(canvas, loadedTexture.image); } + } + + public static void fxAvatarBinding(Canvas canvas, ObservableValue skinBinding) { + synchronized (SkinBindingChangeListener.hole) { + SkinBindingChangeListener oldListener = SkinBindingChangeListener.hole.remove(canvas); + if (oldListener != null) + oldListener.binding.removeListener(oldListener); + + SkinBindingChangeListener listener = new SkinBindingChangeListener(canvas, skinBinding); + listener.changed(skinBinding, null, skinBinding.getValue()); + skinBinding.addListener(listener); + + SkinBindingChangeListener.hole.put(canvas, listener); + } + } - g.dispose(); - return avatar; + public static void bindAvatar(Canvas canvas, YggdrasilService service, UUID uuid) { + fxAvatarBinding(canvas, skinBinding(service, uuid)); } - public static ObjectBinding fxAvatarBinding(YggdrasilService service, UUID uuid, int size) { - return MultiStepBinding.of(skinBinding(service, uuid)) - .map(it -> toAvatar(it.image, size)) - .map(img -> SwingFXUtils.toFXImage(img, null)); + public static void bindAvatar(Canvas canvas, Account account) { + if (account instanceof YggdrasilAccount || account instanceof MicrosoftAccount || account instanceof OfflineAccount) + fxAvatarBinding(canvas, skinBinding(account)); + else { + unbindAvatar(canvas); + drawAvatar(canvas, getDefaultSkin(account.getUUID()).image); + } } - public static ObjectBinding fxAvatarBinding(Account account, int size) { - if (account instanceof YggdrasilAccount) { - return fxAvatarBinding(((YggdrasilAccount) account).getYggdrasilService(), account.getUUID(), size); - } else { - return Bindings.createObjectBinding( - () -> SwingFXUtils.toFXImage(toAvatar(getDefaultSkin(TextureModel.detectUUID(account.getUUID())).image, size), null)); + public static void unbindAvatar(Canvas canvas) { + synchronized (SkinBindingChangeListener.hole) { + SkinBindingChangeListener oldListener = SkinBindingChangeListener.hole.remove(canvas); + if (oldListener != null) + oldListener.binding.removeListener(oldListener); } } // ==== diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/java/HMCLJavaRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/java/HMCLJavaRepository.java new file mode 100644 index 0000000000..b0100219ea --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/java/HMCLJavaRepository.java @@ -0,0 +1,227 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.java; + +import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.download.java.mojang.MojangJavaDownloadTask; +import org.jackhuang.hmcl.download.java.mojang.MojangJavaRemoteFiles; +import org.jackhuang.hmcl.game.DownloadInfo; +import org.jackhuang.hmcl.game.GameJavaVersion; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.platform.Platform; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.*; +import java.util.*; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/** + * @author Glavo + */ +public final class HMCLJavaRepository implements JavaRepository { + public static final String MOJANG_JAVA_PREFIX = "mojang-"; + + private final Path root; + + public HMCLJavaRepository(Path root) { + this.root = root; + } + + public Path getPlatformRoot(Platform platform) { + return root.resolve(platform.toString()); + } + + @Override + public Path getJavaDir(Platform platform, String name) { + return getPlatformRoot(platform).resolve(name); + } + + public Path getJavaDir(Platform platform, GameJavaVersion gameJavaVersion) { + return getJavaDir(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent()); + } + + @Override + public Path getManifestFile(Platform platform, String name) { + return getPlatformRoot(platform).resolve(name + ".json"); + } + + public Path getManifestFile(Platform platform, GameJavaVersion gameJavaVersion) { + return getManifestFile(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent()); + } + + public boolean isInstalled(Platform platform, String name) { + return Files.exists(getManifestFile(platform, name)); + } + + public boolean isInstalled(Platform platform, GameJavaVersion gameJavaVersion) { + return isInstalled(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent()); + } + + public @Nullable Path getJavaExecutable(Platform platform, String name) { + Path javaDir = getJavaDir(platform, name); + try { + return JavaManager.getExecutable(javaDir).toRealPath(); + } catch (IOException ignored) { + if (platform.getOperatingSystem() == OperatingSystem.MACOS) { + try { + return JavaManager.getMacExecutable(javaDir).toRealPath(); + } catch (IOException ignored1) { + } + } + } + + return null; + } + + public @Nullable Path getJavaExecutable(Platform platform, GameJavaVersion gameJavaVersion) { + return getJavaExecutable(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent()); + } + + private static void getAllJava(List list, Platform platform, Path platformRoot, boolean isManaged) { + try (DirectoryStream stream = Files.newDirectoryStream(platformRoot)) { + for (Path file : stream) { + try { + String name = file.getFileName().toString(); + if (name.endsWith(".json") && Files.isRegularFile(file)) { + Path javaDir = file.resolveSibling(name.substring(0, name.length() - ".json".length())); + Path executable; + try { + executable = JavaManager.getExecutable(javaDir).toRealPath(); + } catch (IOException e) { + if (platform.getOperatingSystem() == OperatingSystem.MACOS) + executable = JavaManager.getMacExecutable(javaDir).toRealPath(); + else + throw e; + } + + if (Files.isDirectory(javaDir)) { + JavaManifest manifest = JsonUtils.fromJsonFile(file, JavaManifest.class); + list.add(JavaRuntime.of(executable, manifest.getInfo(), isManaged)); + } + } + } catch (Throwable e) { + LOG.warning("Failed to parse " + file, e); + } + } + + } catch (IOException ignored) { + } + } + + @Override + public Collection getAllJava(Platform platform) { + Path platformRoot = getPlatformRoot(platform); + if (!Files.isDirectory(platformRoot)) + return Collections.emptyList(); + + ArrayList list = new ArrayList<>(); + + getAllJava(list, platform, platformRoot, true); + if (platform.getOperatingSystem() == OperatingSystem.MACOS) { + platformRoot = root.resolve(platform.getOperatingSystem().getMojangName() + "-" + platform.getArchitecture().getCheckedName()); + if (Files.isDirectory(platformRoot)) + getAllJava(list, platform, platformRoot, false); + } + + return list; + } + + @Override + public Task getDownloadJavaTask(DownloadProvider downloadProvider, Platform platform, GameJavaVersion gameJavaVersion) { + Path javaDir = getJavaDir(platform, gameJavaVersion); + + return new MojangJavaDownloadTask(downloadProvider, javaDir, gameJavaVersion, JavaManager.getMojangJavaPlatform(platform)).thenApplyAsync(result -> { + Path executable; + try { + executable = JavaManager.getExecutable(javaDir).toRealPath(); + } catch (IOException e) { + if (platform.getOperatingSystem() == OperatingSystem.MACOS) + executable = JavaManager.getMacExecutable(javaDir).toRealPath(); + else + throw e; + } + + JavaInfo info; + if (JavaManager.isCompatible(platform)) + info = JavaInfoUtils.fromExecutable(executable, false); + else + info = new JavaInfo(platform, result.download.getVersion().getName(), null); + + Map update = new LinkedHashMap<>(); + update.put("provider", "mojang"); + update.put("component", gameJavaVersion.getComponent()); + + Map files = new LinkedHashMap<>(); + result.remoteFiles.getFiles().forEach((path, file) -> { + if (file instanceof MojangJavaRemoteFiles.RemoteFile) { + DownloadInfo downloadInfo = ((MojangJavaRemoteFiles.RemoteFile) file).getDownloads().get("raw"); + if (downloadInfo != null) { + files.put(path, new JavaLocalFiles.LocalFile(downloadInfo.getSha1(), downloadInfo.getSize())); + } + } else if (file instanceof MojangJavaRemoteFiles.RemoteDirectory) { + files.put(path, new JavaLocalFiles.LocalDirectory()); + } else if (file instanceof MojangJavaRemoteFiles.RemoteLink) { + files.put(path, new JavaLocalFiles.LocalLink(((MojangJavaRemoteFiles.RemoteLink) file).getTarget())); + } + }); + + JavaManifest manifest = new JavaManifest(info, update, files); + JsonUtils.writeToJsonFile(getManifestFile(platform, gameJavaVersion), manifest); + return JavaRuntime.of(executable, info, true); + }); + } + + public Task getInstallJavaTask(Platform platform, String name, Map update, Path archiveFile) { + Path javaDir = getJavaDir(platform, name); + return new JavaInstallTask(javaDir, update, archiveFile).thenApplyAsync(result -> { + if (!result.getInfo().getPlatform().equals(platform)) + throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.getInfo().getPlatform()); + + Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath(); + JsonUtils.writeToJsonFile(getManifestFile(platform, name), result); + return JavaRuntime.of(executable, result.getInfo(), true); + }); + } + + @Override + public Task getUninstallJavaTask(Platform platform, String name) { + return Task.runAsync(() -> { + Files.deleteIfExists(getManifestFile(platform, name)); + FileUtils.deleteDirectory(getJavaDir(platform, name)); + }); + } + + @Override + public Task getUninstallJavaTask(JavaRuntime java) { + return Task.runAsync(() -> { + Path root = getPlatformRoot(java.getPlatform()); + Path relativized = root.relativize(java.getBinary()); + + if (relativized.getNameCount() > 1) { + String name = relativized.getName(0).toString(); + Files.deleteIfExists(getManifestFile(java.getPlatform(), name)); + FileUtils.deleteDirectory(getJavaDir(java.getPlatform(), name)); + } + }); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaInfoUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaInfoUtils.java new file mode 100644 index 0000000000..f67e4b1639 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaInfoUtils.java @@ -0,0 +1,116 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.java; + +import com.google.gson.annotations.SerializedName; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.platform.Architecture; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.platform.Platform; +import org.jackhuang.hmcl.util.platform.SystemUtils; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * @author Glavo + * @see Glavo/java-info + */ +public final class JavaInfoUtils { + + private JavaInfoUtils() { + } + + private static Path tryFindReleaseFile(Path executable) { + Path parent = executable.getParent(); + if (parent != null && parent.getFileName() != null && parent.getFileName().toString().equals("bin")) { + Path javaHome = parent.getParent(); + if (javaHome != null && javaHome.getFileName() != null) { + Path releaseFile = javaHome.resolve("release"); + String javaHomeName = javaHome.getFileName().toString(); + if ((javaHomeName.contains("jre") || javaHomeName.contains("jdk") || javaHomeName.contains("openj9")) + && Files.isRegularFile(releaseFile)) { + return releaseFile; + } + } + } + return null; + } + + public static @NotNull JavaInfo fromExecutable(Path executable, boolean tryFindReleaseFile) throws IOException { + assert executable.isAbsolute(); + + Path releaseFile; + if (tryFindReleaseFile && (releaseFile = tryFindReleaseFile(executable)) != null) { + try { + return JavaInfo.fromReleaseFile(releaseFile); + } catch (IOException ignored) { + } + } + + Path thisPath = JarUtils.thisJarPath(); + + if (thisPath == null) { + throw new IOException("Failed to find current HMCL location"); + } + + try { + Result result = JsonUtils.GSON.fromJson(SystemUtils.run( + executable.toString(), + "-classpath", + thisPath.toString(), + org.glavo.info.Main.class.getName() + ), Result.class); + + if (result == null) { + throw new IOException("Failed to get Java info from " + executable); + } + + if (result.javaVersion == null) { + throw new IOException("Failed to get Java version from " + executable); + } + + Architecture architecture = Architecture.parseArchName(result.osArch); + Platform platform = Platform.getPlatform(OperatingSystem.CURRENT_OS, + architecture != Architecture.UNKNOWN + ? architecture + : Architecture.SYSTEM_ARCH); + + + return new JavaInfo(platform, result.javaVersion, result.javaVendor); + } catch (IOException e) { + throw e; + } catch (Throwable e) { + throw new IOException(e); + } + } + + private static final class Result { + @SerializedName("os.name") + public String osName; + @SerializedName("os.arch") + public String osArch; + @SerializedName("java.version") + public String javaVersion; + @SerializedName("java.vendor") + public String javaVendor; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaInstallTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaInstallTask.java new file mode 100644 index 0000000000..f6ba84e3bf --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaInstallTask.java @@ -0,0 +1,118 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.java; + +import kala.compress.archivers.ArchiveEntry; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.DigestUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.io.IOUtils; +import org.jackhuang.hmcl.util.tree.ArchiveFileTree; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * @author Glavo + */ +public final class JavaInstallTask extends Task { + + private final Path targetDir; + private final Map update; + private final Path archiveFile; + + private final Map files = new LinkedHashMap<>(); + private final ArrayList nameStack = new ArrayList<>(); + private final byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE]; + private final MessageDigest messageDigest = DigestUtils.getDigest("SHA-1"); + + public JavaInstallTask(Path targetDir, Map update, Path archiveFile) { + this.targetDir = targetDir; + this.update = update; + this.archiveFile = archiveFile; + } + + @Override + public void execute() throws Exception { + JavaInfo info; + + try (ArchiveFileTree tree = ArchiveFileTree.open(archiveFile)) { + info = JavaInfo.fromArchive(tree); + copyDirContent(tree, targetDir); + } + + setResult(new JavaManifest(info, update, files)); + } + + private void copyDirContent(ArchiveFileTree tree, Path targetDir) throws IOException { + copyDirContent(tree, tree.getRoot().getSubDirs().values().iterator().next(), targetDir); + } + + private void copyDirContent(ArchiveFileTree tree, ArchiveFileTree.Dir dir, Path targetDir) throws IOException { + Files.createDirectories(targetDir); + + for (Map.Entry pair : dir.getFiles().entrySet()) { + Path path = targetDir.resolve(pair.getKey()); + E entry = pair.getValue(); + + nameStack.add(pair.getKey()); + if (tree.isLink(entry)) { + String linkTarget = tree.getLink(entry); + files.put(String.join("/", nameStack), new JavaLocalFiles.LocalLink(linkTarget)); + Files.createSymbolicLink(path, Paths.get(linkTarget)); + } else { + long size = 0L; + + try (InputStream input = tree.getInputStream(entry); + OutputStream output = Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { + messageDigest.reset(); + + int c; + while ((c = input.read(buffer)) > 0) { + size += c; + output.write(buffer, 0, c); + messageDigest.update(buffer, 0, c); + } + } + + if (tree.isExecutable(entry)) + FileUtils.setExecutable(path); + + files.put(String.join("/", nameStack), new JavaLocalFiles.LocalFile(HexFormat.of().formatHex(messageDigest.digest()), size)); + } + nameStack.remove(nameStack.size() - 1); + } + + for (Map.Entry> pair : dir.getSubDirs().entrySet()) { + nameStack.add(pair.getKey()); + files.put(String.join("/", nameStack), new JavaLocalFiles.LocalDirectory()); + copyDirContent(tree, pair.getValue(), targetDir.resolve(pair.getKey())); + nameStack.remove(nameStack.size() - 1); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaLocalFiles.java b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaLocalFiles.java new file mode 100644 index 0000000000..592acec56e --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaLocalFiles.java @@ -0,0 +1,128 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.java; + +import com.google.gson.*; +import com.google.gson.annotations.JsonAdapter; + +import java.lang.reflect.Type; + +/** + * @author Glavo + */ +public final class JavaLocalFiles { + @JsonAdapter(Serializer.class) + public abstract static class Local { + private final String type; + + Local(String type) { + this.type = type; + } + + public String getType() { + return type; + } + } + + public static final class LocalFile extends Local { + private final String sha1; + private final long size; + + public LocalFile(String sha1, long size) { + super("file"); + this.sha1 = sha1; + this.size = size; + } + + public String getSha1() { + return sha1; + } + + public long getSize() { + return size; + } + } + + public static final class LocalDirectory extends Local { + public LocalDirectory() { + super("directory"); + } + } + + public static final class LocalLink extends Local { + private final String target; + + public LocalLink(String target) { + super("link"); + this.target = target; + } + + public String getTarget() { + return target; + } + } + + public static class Serializer implements JsonSerializer, JsonDeserializer { + + @Override + public JsonElement serialize(Local src, Type typeOfSrc, JsonSerializationContext context) { + JsonObject obj = new JsonObject(); + obj.addProperty("type", src.getType()); + if (src instanceof LocalFile) { + obj.addProperty("sha1", ((LocalFile) src).getSha1()); + obj.addProperty("size", ((LocalFile) src).getSize()); + } else if (src instanceof LocalLink) { + obj.addProperty("target", ((LocalLink) src).getTarget()); + } + return obj; + } + + @Override + public Local deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + if (!json.isJsonObject()) + throw new JsonParseException(json.toString()); + + JsonObject obj = json.getAsJsonObject(); + if (!obj.has("type")) + throw new JsonParseException(json.toString()); + + String type = obj.getAsJsonPrimitive("type").getAsString(); + + try { + switch (type) { + case "file": { + String sha1 = obj.getAsJsonPrimitive("sha1").getAsString(); + long size = obj.getAsJsonPrimitive("size").getAsLong(); + return new LocalFile(sha1, size); + } + case "directory": { + return new LocalDirectory(); + } + case "link": { + String target = obj.getAsJsonPrimitive("target").getAsString(); + return new LocalLink(target); + } + default: + throw new AssertionError("unknown type: " + type); + } + } catch (Throwable e) { + throw new JsonParseException(json.toString()); + } + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java new file mode 100644 index 0000000000..4fa82750b4 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java @@ -0,0 +1,679 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.java; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameJavaVersion; +import org.jackhuang.hmcl.game.JavaVersionConstraint; +import org.jackhuang.hmcl.game.Version; +import org.jackhuang.hmcl.setting.ConfigHolder; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.CacheRepository; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.platform.*; +import org.jackhuang.hmcl.util.platform.windows.WinReg; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.Nullable; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.CountDownLatch; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/** + * @author Glavo + */ +public final class JavaManager { + + private JavaManager() { + } + + public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("java")); + public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_CURRENT_DIRECTORY.resolve("java")); + + public static String getMojangJavaPlatform(Platform platform) { + if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) { + if (Architecture.SYSTEM_ARCH == Architecture.X86) { + return "windows-x86"; + } else if (Architecture.SYSTEM_ARCH == Architecture.X86_64) { + return "windows-x64"; + } else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) { + return "windows-arm64"; + } + } else if (platform.getOperatingSystem() == OperatingSystem.LINUX) { + if (Architecture.SYSTEM_ARCH == Architecture.X86) { + return "linux-i386"; + } else if (Architecture.SYSTEM_ARCH == Architecture.X86_64) { + return "linux"; + } + } else if (platform.getOperatingSystem() == OperatingSystem.MACOS) { + if (Architecture.SYSTEM_ARCH == Architecture.X86_64) { + return "mac-os"; + } else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) { + return "mac-os-arm64"; + } + } + + return null; + } + + public static Path getExecutable(Path javaHome) { + return javaHome.resolve("bin").resolve(OperatingSystem.CURRENT_OS.getJavaExecutable()); + } + + public static Path getMacExecutable(Path javaHome) { + return javaHome.resolve("jre.bundle/Contents/Home/bin/java"); + } + + public static boolean isCompatible(Platform platform) { + if (platform.getOperatingSystem() != OperatingSystem.CURRENT_OS) + return false; + + Architecture architecture = platform.getArchitecture(); + if (architecture == Architecture.SYSTEM_ARCH || architecture == Architecture.CURRENT_ARCH) + return true; + + switch (OperatingSystem.CURRENT_OS) { + case WINDOWS: + if (Architecture.SYSTEM_ARCH == Architecture.X86_64) + return architecture == Architecture.X86; + if (Architecture.SYSTEM_ARCH == Architecture.ARM64) + return OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277 && architecture == Architecture.X86_64 || architecture == Architecture.X86; + break; + case LINUX: + if (Architecture.SYSTEM_ARCH == Architecture.X86_64) + return architecture == Architecture.X86; + break; + case MACOS: + if (Architecture.SYSTEM_ARCH == Architecture.ARM64) + return architecture == Architecture.X86_64; + break; + } + + return false; + } + + private static volatile Map allJava; + private static final CountDownLatch LATCH = new CountDownLatch(1); + + private static final ObjectProperty> allJavaProperty = new SimpleObjectProperty<>(); + + private static Map getAllJavaMap() throws InterruptedException { + Map map = allJava; + if (map == null) { + LATCH.await(); + map = allJava; + } + return map; + } + + private static void updateAllJavaProperty(Map javaRuntimes) { + JavaRuntime[] array = javaRuntimes.values().toArray(new JavaRuntime[0]); + Arrays.sort(array); + allJavaProperty.set(Arrays.asList(array)); + } + + public static boolean isInitialized() { + return allJava != null; + } + + public static Collection getAllJava() throws InterruptedException { + return getAllJavaMap().values(); + } + + public static ObjectProperty> getAllJavaProperty() { + return allJavaProperty; + } + + public static JavaRuntime getJava(Path executable) throws IOException, InterruptedException { + executable = executable.toRealPath(); + + JavaRuntime javaRuntime = getAllJavaMap().get(executable); + if (javaRuntime != null) { + return javaRuntime; + } + + JavaInfo info = JavaInfoUtils.fromExecutable(executable, true); + return JavaRuntime.of(executable, info, false); + } + + public static void refresh() { + Task.supplyAsync(JavaManager::searchPotentialJavaExecutables).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (result != null) { + LATCH.await(); + allJava = result; + updateAllJavaProperty(result); + } + }).start(); + } + + public static Task getAddJavaTask(Path binary) { + return Task.supplyAsync("Get Java", () -> JavaManager.getJava(binary)) + .thenApplyAsync(Schedulers.javafx(), javaRuntime -> { + if (!JavaManager.isCompatible(javaRuntime.getPlatform())) { + throw new UnsupportedPlatformException("Incompatible platform: " + javaRuntime.getPlatform()); + } + + String pathString = javaRuntime.getBinary().toString(); + + ConfigHolder.globalConfig().getDisabledJava().remove(pathString); + if (ConfigHolder.globalConfig().getUserJava().add(pathString)) { + addJava(javaRuntime); + } + return javaRuntime; + }); + } + + public static Task getDownloadJavaTask(DownloadProvider downloadProvider, Platform platform, GameJavaVersion gameJavaVersion) { + return REPOSITORY.getDownloadJavaTask(downloadProvider, platform, gameJavaVersion) + .thenApplyAsync(Schedulers.javafx(), java -> { + addJava(java); + return java; + }); + } + + public static Task getInstallJavaTask(Platform platform, String name, Map update, Path archiveFile) { + return REPOSITORY.getInstallJavaTask(platform, name, update, archiveFile) + .thenApplyAsync(Schedulers.javafx(), java -> { + addJava(java); + return java; + }); + } + + public static Task getUninstallJavaTask(JavaRuntime java) { + assert java.isManaged(); + + Path platformRoot; + try { + platformRoot = REPOSITORY.getPlatformRoot(java.getPlatform()).toRealPath(); + } catch (Throwable ignored) { + return Task.completed(null); + } + + if (!java.getBinary().startsWith(platformRoot)) + return Task.completed(null); + + Path relativized = platformRoot.relativize(java.getBinary()); + if (relativized.getNameCount() > 1) { + FXUtils.runInFX(() -> { + try { + removeJava(java); + } catch (InterruptedException e) { + throw new AssertionError("Unreachable code", e); + } + }); + + String name = relativized.getName(0).toString(); + return REPOSITORY.getUninstallJavaTask(java.getPlatform(), name); + } else { + return Task.completed(null); + } + } + + // FXThread + public static void addJava(JavaRuntime java) throws InterruptedException { + Map oldMap = getAllJavaMap(); + if (!oldMap.containsKey(java.getBinary())) { + HashMap newMap = new HashMap<>(oldMap); + newMap.put(java.getBinary(), java); + allJava = newMap; + updateAllJavaProperty(newMap); + } + } + + // FXThread + public static void removeJava(JavaRuntime java) throws InterruptedException { + removeJava(java.getBinary()); + } + + // FXThread + public static void removeJava(Path realPath) throws InterruptedException { + Map oldMap = getAllJavaMap(); + if (oldMap.containsKey(realPath)) { + HashMap newMap = new HashMap<>(oldMap); + newMap.remove(realPath); + allJava = newMap; + updateAllJavaProperty(newMap); + } + } + + private static JavaRuntime chooseJava(@Nullable JavaRuntime java1, JavaRuntime java2) { + if (java1 == null) + return java2; + + if (java1.getParsedVersion() != java2.getParsedVersion()) + // Prefer the Java version that is closer to the game's recommended Java version + return java1.getParsedVersion() < java2.getParsedVersion() ? java1 : java2; + else + return java1.getVersionNumber().compareTo(java2.getVersionNumber()) >= 0 ? java1 : java2; + } + + @Nullable + public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, Version version) throws InterruptedException { + return findSuitableJava(getAllJava(), gameVersion, version); + } + + @Nullable + public static JavaRuntime findSuitableJava(Collection javaRuntimes, GameVersionNumber gameVersion, Version version) { + LibraryAnalyzer analyzer = version != null ? LibraryAnalyzer.analyze(version, gameVersion != null ? gameVersion.toString() : null) : null; + + boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64 + && (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) + && (gameVersion == null || gameVersion.compareTo("1.6") < 0); + + JavaRuntime mandatory = null; + JavaRuntime suggested = null; + for (JavaRuntime java : javaRuntimes) { + if (forceX86) { + if (!java.getArchitecture().isX86()) + continue; + } else { + if (java.getArchitecture() != Architecture.SYSTEM_ARCH) + continue; + } + + boolean violationMandatory = false; + boolean violationSuggested = false; + + for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) { + if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) { + if (!constraint.checkJava(gameVersion, version, java)) { + if (constraint.isMandatory()) { + violationMandatory = true; + } else { + violationSuggested = true; + } + } + } + } + + if (!violationMandatory) { + mandatory = chooseJava(mandatory, java); + + if (!violationSuggested) + suggested = chooseJava(suggested, java); + } + } + + return suggested != null ? suggested : mandatory; + } + + public static void initialize() { + Map allJava = searchPotentialJavaExecutables(); + JavaManager.allJava = allJava; + LATCH.countDown(); + FXUtils.runInFX(() -> updateAllJavaProperty(allJava)); + } + + // search java + + private static Map searchPotentialJavaExecutables() { + Map javaRuntimes = new HashMap<>(); + searchAllJavaInRepository(javaRuntimes, Platform.SYSTEM_PLATFORM); + switch (OperatingSystem.CURRENT_OS) { + case WINDOWS: + if (Architecture.SYSTEM_ARCH == Architecture.X86_64) + searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86); + if (Architecture.SYSTEM_ARCH == Architecture.ARM64) { + if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) + searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86_64); + searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86); + } + break; + case MACOS: + if (Architecture.SYSTEM_ARCH == Architecture.ARM64) + searchAllJavaInRepository(javaRuntimes, Platform.MACOS_X86_64); + break; + } + + switch (OperatingSystem.CURRENT_OS) { + case WINDOWS: + queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Runtime Environment"); + queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Development Kit"); + queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JRE"); + queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JDK"); + + searchJavaInProgramFiles(javaRuntimes, "ProgramFiles", "C:\\Program Files"); + searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(x86)", "C:\\Program Files (x86)"); + if (Architecture.SYSTEM_ARCH == Architecture.ARM64) { + searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(ARM)", "C:\\Program Files (ARM)"); + } + break; + case LINUX: + searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/java")); // Oracle RPMs + searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib/jvm")); // General locations + searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib32/jvm")); // General locations + searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib64/jvm")); // General locations + searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), "/.sdkman/candidates/java")); // SDKMAN! + break; + case MACOS: + searchJavaInMacJavaVirtualMachines(javaRuntimes, Paths.get("/Library/Java/JavaVirtualMachines")); + searchJavaInMacJavaVirtualMachines(javaRuntimes, Paths.get(System.getProperty("user.home"), "/Library/Java/JavaVirtualMachines")); + tryAddJavaExecutable(javaRuntimes, Paths.get("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java")); + tryAddJavaExecutable(javaRuntimes, Paths.get("/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/java/bin/java")); + // Homebrew + tryAddJavaExecutable(javaRuntimes, Paths.get("/opt/homebrew/opt/java/bin/java")); + searchAllJavaInDirectory(javaRuntimes, Paths.get("/opt/homebrew/Cellar/openjdk")); + try (DirectoryStream dirs = Files.newDirectoryStream(Paths.get("/opt/homebrew/Cellar"), "openjdk@*")) { + for (Path dir : dirs) { + searchAllJavaInDirectory(javaRuntimes, dir); + } + } catch (IOException e) { + LOG.warning("Failed to get subdirectories of /opt/homebrew/Cellar"); + } + break; + + default: + break; + } + + // Search Minecraft bundled runtimes + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH.isX86()) { + FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime") + .ifPresent(it -> searchAllOfficialJava(javaRuntimes, it, false)); + + FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime") + .ifPresent(it -> searchAllOfficialJava(javaRuntimes, it, false)); + } else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) { + searchAllOfficialJava(javaRuntimes, Paths.get(System.getProperty("user.home"), ".minecraft/runtime"), false); + } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { + searchAllOfficialJava(javaRuntimes, Paths.get(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false); + } + searchAllOfficialJava(javaRuntimes, CacheRepository.getInstance().getCacheDirectory().resolve("java"), true); + + // Search in PATH. + if (System.getenv("PATH") != null) { + String[] paths = System.getenv("PATH").split(File.pathSeparator); + for (String path : paths) { + // https://github.com/HMCL-dev/HMCL/issues/4079 + // https://github.com/Meloong-Git/PCL/issues/4261 + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && path.toLowerCase(Locale.ROOT) + .contains("\\common files\\oracle\\java\\")) { + continue; + } + + try { + tryAddJavaExecutable(javaRuntimes, Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable())); + } catch (InvalidPathException ignored) { + } + } + } + + if (System.getenv("HMCL_JRES") != null) { + String[] paths = System.getenv("HMCL_JRES").split(File.pathSeparator); + for (String path : paths) { + try { + tryAddJavaHome(javaRuntimes, Paths.get(path)); + } catch (InvalidPathException ignored) { + } + } + } + searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), ".jdks")); + + for (String javaPath : ConfigHolder.globalConfig().getUserJava()) { + try { + tryAddJavaExecutable(javaRuntimes, Paths.get(javaPath)); + } catch (InvalidPathException e) { + LOG.warning("Invalid Java path: " + javaPath); + } + } + + JavaRuntime currentJava = JavaRuntime.CURRENT_JAVA; + if (currentJava != null + && !javaRuntimes.containsKey(currentJava.getBinary()) + && !ConfigHolder.globalConfig().getDisabledJava().contains(currentJava.getBinary().toString())) { + javaRuntimes.put(currentJava.getBinary(), currentJava); + } + + LOG.trace(javaRuntimes.values().stream().sorted() + .map(it -> String.format(" - %s %s (%s, %s): %s", + it.isJDK() ? "JDK" : "JRE", + it.getVersion(), + it.getPlatform().getArchitecture().getDisplayName(), + Lang.requireNonNullElse(it.getVendor(), "Unknown"), + it.getBinary())) + .collect(Collectors.joining("\n", "Finished Java lookup, found " + javaRuntimes.size() + "\n", ""))); + + return javaRuntimes; + } + + private static void tryAddJavaHome(Map javaRuntimes, Path javaHome) { + Path executable = getExecutable(javaHome); + if (!Files.isRegularFile(executable)) { + return; + } + + try { + executable = executable.toRealPath(); + } catch (IOException e) { + LOG.warning("Failed to resolve path " + executable, e); + return; + } + + if (javaRuntimes.containsKey(executable) || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) { + return; + } + + JavaInfo info = null; + + Path releaseFile = javaHome.resolve("release"); + if (Files.exists(releaseFile)) { + try { + info = JavaInfo.fromReleaseFile(releaseFile); + } catch (IOException e) { + LOG.warning("Failed to read release file " + releaseFile, e); + } + } + + if (info == null) { + try { + info = JavaInfoUtils.fromExecutable(executable, false); + } catch (IOException e) { + LOG.warning("Failed to lookup Java executable at " + executable, e); + } + } + + if (info != null && isCompatible(info.getPlatform())) + javaRuntimes.put(executable, JavaRuntime.of(executable, info, false)); + } + + private static void tryAddJavaExecutable(Map javaRuntimes, Path executable) { + try { + executable = executable.toRealPath(); + } catch (IOException e) { + return; + } + + if (javaRuntimes.containsKey(executable) || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) { + return; + } + + JavaInfo info = null; + try { + info = JavaInfoUtils.fromExecutable(executable, true); + } catch (IOException e) { + LOG.warning("Failed to lookup Java executable at " + executable, e); + } + + if (info != null && isCompatible(info.getPlatform())) { + javaRuntimes.put(executable, JavaRuntime.of(executable, info, false)); + } + } + + private static void tryAddJavaInComponentDir(Map javaRuntimes, String platform, Path component, boolean verify) { + Path sha1File = component.resolve(platform).resolve(component.getFileName() + ".sha1"); + if (!Files.isRegularFile(sha1File)) + return; + + Path dir = component.resolve(platform).resolve(component.getFileName()); + + if (verify) { + try (BufferedReader reader = Files.newBufferedReader(sha1File)) { + String line; + while ((line = reader.readLine()) != null) { + if (line.isEmpty()) continue; + + int idx = line.indexOf(" /#//"); + if (idx <= 0) + throw new IOException("Illegal line: " + line); + + Path file = dir.resolve(line.substring(0, idx)); + + // Should we check the sha1 of files? This will take a lot of time. + if (Files.notExists(file)) + throw new NoSuchFileException(file.toAbsolutePath().toString()); + } + } catch (IOException e) { + LOG.warning("Failed to verify Java in " + component, e); + return; + } + } + + if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { + Path macPath = dir.resolve("jre.bundle/Contents/Home"); + if (Files.exists(macPath)) { + tryAddJavaHome(javaRuntimes, macPath); + return; + } else + LOG.warning("The Java is not in 'jre.bundle/Contents/Home'"); + } + + tryAddJavaHome(javaRuntimes, dir); + } + + private static void searchAllJavaInRepository(Map javaRuntimes, Platform platform) { + for (JavaRuntime java : REPOSITORY.getAllJava(platform)) { + javaRuntimes.put(java.getBinary(), java); + } + + for (JavaRuntime java : LOCAL_REPOSITORY.getAllJava(platform)) { + javaRuntimes.put(java.getBinary(), java); + } + } + + private static void searchAllOfficialJava(Map javaRuntimes, Path directory, boolean verify) { + if (!Files.isDirectory(directory)) + return; + // Examples: + // $HOME/Library/Application Support/minecraft/runtime/java-runtime-beta/mac-os/java-runtime-beta/jre.bundle/Contents/Home + // $HOME/.minecraft/runtime/java-runtime-beta/linux/java-runtime-beta + + String javaPlatform = getMojangJavaPlatform(Platform.SYSTEM_PLATFORM); + if (javaPlatform != null) { + searchAllOfficialJava(javaRuntimes, directory, javaPlatform, verify); + } + + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { + if (Architecture.SYSTEM_ARCH == Architecture.X86_64) { + searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify); + } else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) { + if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) { + searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify); + } + searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify); + } + } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.CURRENT_ARCH == Architecture.ARM64) { + searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify); + } + } + + private static void searchAllOfficialJava(Map javaRuntimes, Path directory, String platform, boolean verify) { + try (DirectoryStream dir = Files.newDirectoryStream(directory)) { + // component can be jre-legacy, java-runtime-alpha, java-runtime-beta, java-runtime-gamma or any other being added in the future. + for (Path component : dir) { + tryAddJavaInComponentDir(javaRuntimes, platform, component, verify); + } + } catch (IOException e) { + LOG.warning("Failed to list java-runtime directory " + directory, e); + } + } + + private static void searchAllJavaInDirectory(Map javaRuntimes, Path directory) { + if (!Files.isDirectory(directory)) { + return; + } + + try (DirectoryStream stream = Files.newDirectoryStream(directory)) { + for (Path subDir : stream) { + tryAddJavaHome(javaRuntimes, subDir); + } + } catch (IOException e) { + LOG.warning("Failed to find Java in " + directory, e); + } + } + + private static void searchJavaInProgramFiles(Map javaRuntimes, String env, String defaultValue) { + String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue); + Path path; + try { + path = Paths.get(programFiles); + } catch (InvalidPathException ignored) { + return; + } + + for (String vendor : new String[]{"Java", "BellSoft", "AdoptOpenJDK", "Zulu", "Microsoft", "Eclipse Foundation", "Semeru"}) { + searchAllJavaInDirectory(javaRuntimes, path.resolve(vendor)); + } + } + + private static void searchJavaInMacJavaVirtualMachines(Map javaRuntimes, Path directory) { + if (!Files.isDirectory(directory)) { + return; + } + + try (DirectoryStream stream = Files.newDirectoryStream(directory)) { + for (Path subDir : stream) { + tryAddJavaHome(javaRuntimes, subDir.resolve("Contents/Home")); + } + } catch (IOException e) { + LOG.warning("Failed to find Java in " + directory, e); + } + } + + // ==== Windows Registry Support ==== + private static void queryJavaInRegistryKey(Map javaRuntimes, WinReg.HKEY hkey, String location) { + WinReg reg = WinReg.INSTANCE; + if (reg == null) + return; + + for (String java : reg.querySubKeys(hkey, location)) { + if (!reg.querySubKeys(hkey, java).contains(java + "\\MSI")) + continue; + Object home = reg.queryValue(hkey, java, "JavaHome"); + if (home instanceof String) { + try { + tryAddJavaHome(javaRuntimes, Paths.get((String) home)); + } catch (InvalidPathException e) { + LOG.warning("Invalid Java path in system registry: " + home); + } + } + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManifest.java b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManifest.java new file mode 100644 index 0000000000..856b2a7195 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManifest.java @@ -0,0 +1,112 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.java; + +import com.google.gson.*; +import com.google.gson.annotations.JsonAdapter; +import org.jackhuang.hmcl.util.platform.Architecture; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.platform.Platform; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Type; +import java.util.Map; +import java.util.Optional; + +import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf; + +/** + * @author Glavo + */ +@JsonAdapter(JavaManifest.Serializer.class) +public final class JavaManifest { + + private final JavaInfo info; + + @Nullable + private final Map update; + + @Nullable + private final Map files; + + public JavaManifest(JavaInfo info, @Nullable Map update, @Nullable Map files) { + this.info = info; + this.update = update; + this.files = files; + } + + public JavaInfo getInfo() { + return info; + } + + public Map getUpdate() { + return update; + } + + public Map getFiles() { + return files; + } + + public static final class Serializer implements JsonSerializer, JsonDeserializer { + + private static final Type LOCAL_FILES_TYPE = mapTypeOf(String.class, JavaLocalFiles.Local.class).getType(); + + @Override + public JsonElement serialize(JavaManifest src, Type typeOfSrc, JsonSerializationContext context) { + JsonObject res = new JsonObject(); + res.addProperty("os.name", src.getInfo().getPlatform().getOperatingSystem().getCheckedName()); + res.addProperty("os.arch", src.getInfo().getPlatform().getArchitecture().getCheckedName()); + res.addProperty("java.version", src.getInfo().getVersion()); + res.addProperty("java.vendor", src.getInfo().getVendor()); + + if (src.getUpdate() != null) + res.add("update", context.serialize(src.getUpdate())); + + if (src.getFiles() != null) + res.add("files", context.serialize(src.getFiles(), LOCAL_FILES_TYPE)); + + return res; + } + + @Override + public JavaManifest deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + if (!json.isJsonObject()) + throw new JsonParseException(json.toString()); + + try { + JsonObject jsonObject = json.getAsJsonObject(); + OperatingSystem osName = OperatingSystem.parseOSName(jsonObject.getAsJsonPrimitive("os.name").getAsString()); + Architecture osArch = Architecture.parseArchName(jsonObject.getAsJsonPrimitive("os.arch").getAsString()); + String javaVersion = jsonObject.getAsJsonPrimitive("java.version").getAsString(); + String javaVendor = Optional.ofNullable(jsonObject.get("java.vendor")).map(JsonElement::getAsString).orElse(null); + + Map update = jsonObject.has("update") ? context.deserialize(jsonObject.get("update"), Map.class) : null; + Map files = jsonObject.has("files") ? context.deserialize(jsonObject.get("files"), LOCAL_FILES_TYPE) : null; + + if (osName == null || osArch == null || javaVersion == null) + throw new JsonParseException(json.toString()); + + return new JavaManifest(new JavaInfo(Platform.getPlatform(osName, osArch), javaVersion, javaVendor), update, files); + } catch (JsonParseException e) { + throw e; + } catch (Throwable e) { + throw new JsonParseException(e); + } + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java index c2097f290c..edc1d4e331 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,74 +17,96 @@ */ package org.jackhuang.hmcl.setting; +import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.property.ObjectProperty; -import javafx.beans.property.ReadOnlyListProperty; -import javafx.beans.property.ReadOnlyListWrapper; import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.auth.AccountFactory; -import org.jackhuang.hmcl.auth.AuthenticationException; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccountFactory; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactProvider; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloader; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; -import org.jackhuang.hmcl.auth.authlibinjector.SimpleAuthlibInjectorArtifactProvider; +import org.jackhuang.hmcl.auth.*; +import org.jackhuang.hmcl.auth.authlibinjector.*; +import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; +import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccountFactory; +import org.jackhuang.hmcl.auth.microsoft.MicrosoftService; import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory; -import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; -import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory; +import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; +import org.jackhuang.hmcl.game.OAuthServer; import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.util.FileSaver; +import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.skin.InvalidSkinException; +import javax.net.ssl.SSLException; import java.io.IOException; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.logging.Level; +import java.util.*; import static java.util.stream.Collectors.toList; import static javafx.collections.FXCollections.observableArrayList; import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; +import static org.jackhuang.hmcl.util.Lang.immutableListOf; import static org.jackhuang.hmcl.util.Lang.mapOf; -import static org.jackhuang.hmcl.util.Logging.LOG; import static org.jackhuang.hmcl.util.Pair.pair; +import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf; +import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author huangyuhui */ public final class Accounts { - private Accounts() {} + private Accounts() { + } + + private static final AuthlibInjectorArtifactProvider AUTHLIB_INJECTOR_DOWNLOADER = createAuthlibInjectorArtifactProvider(); - public static final OfflineAccountFactory FACTORY_OFFLINE = OfflineAccountFactory.INSTANCE; - public static final YggdrasilAccountFactory FACTORY_MOJANG = YggdrasilAccountFactory.MOJANG; - public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(createAuthlibInjectorArtifactProvider(), Accounts::getOrCreateAuthlibInjectorServer); + public static final OAuthServer.Factory OAUTH_CALLBACK = new OAuthServer.Factory(); + + public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER); + public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer); + public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK)); + public static final List> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR); // ==== login type / account factory mapping ==== private static final Map> type2factory = new HashMap<>(); private static final Map, String> factory2type = new HashMap<>(); + static { type2factory.put("offline", FACTORY_OFFLINE); - type2factory.put("yggdrasil", FACTORY_MOJANG); type2factory.put("authlibInjector", FACTORY_AUTHLIB_INJECTOR); + type2factory.put("microsoft", FACTORY_MICROSOFT); type2factory.forEach((type, factory) -> factory2type.put(factory, type)); } public static String getLoginType(AccountFactory factory) { - return Optional.ofNullable(factory2type.get(factory)) - .orElseThrow(() -> new IllegalArgumentException("Unrecognized account factory")); + String type = factory2type.get(factory); + if (type != null) return type; + + if (factory instanceof BoundAuthlibInjectorAccountFactory) { + return factory2type.get(FACTORY_AUTHLIB_INJECTOR); + } + + throw new IllegalArgumentException("Unrecognized account factory"); } public static AccountFactory getAccountFactory(String loginType) { return Optional.ofNullable(type2factory.get(loginType)) .orElseThrow(() -> new IllegalArgumentException("Unrecognized login type")); } + + public static BoundAuthlibInjectorAccountFactory getAccountFactoryByAuthlibInjectorServer(AuthlibInjectorServer server) { + return new BoundAuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, server); + } // ==== public static AccountFactory getAccountFactory(Account account) { @@ -92,65 +114,26 @@ public static AccountFactory getAccountFactory(Account account) { return FACTORY_OFFLINE; else if (account instanceof AuthlibInjectorAccount) return FACTORY_AUTHLIB_INJECTOR; - else if (account instanceof YggdrasilAccount) - return FACTORY_MOJANG; + else if (account instanceof MicrosoftAccount) + return FACTORY_MICROSOFT; else throw new IllegalArgumentException("Failed to determine account type: " + account); } - private static ObservableList accounts = observableArrayList(account -> new Observable[] { account }); - private static ReadOnlyListWrapper accountsWrapper = new ReadOnlyListWrapper<>(Accounts.class, "accounts", accounts); - - private static ObjectProperty selectedAccount = new SimpleObjectProperty(Accounts.class, "selectedAccount") { - { - accounts.addListener(onInvalidating(this::invalidated)); - } + private static final String GLOBAL_PREFIX = "$GLOBAL:"; + private static final ObservableList> globalAccountStorages = FXCollections.observableArrayList(); - @Override - protected void invalidated() { - // this methods first checks whether the current selection is valid - // if it's valid, the underlying storage will be updated - // otherwise, the first account will be selected as an alternative(or null if accounts is empty) - Account selected = get(); - if (accounts.isEmpty()) { - if (selected == null) { - // valid - } else { - // the previously selected account is gone, we can only set it to null here - set(null); - return; - } - } else { - if (accounts.contains(selected)) { - // valid - } else { - // the previously selected account is gone - set(accounts.get(0)); - return; - } - } - // selection is valid, store it - if (!initialized) - return; - updateAccountStorages(); - } - }; + private static final ObservableList accounts = observableArrayList(account -> new Observable[]{account}); + private static final ObjectProperty selectedAccount = new SimpleObjectProperty<>(Accounts.class, "selectedAccount"); /** * True if {@link #init()} hasn't been called. */ private static boolean initialized = false; - static { - accounts.addListener(onInvalidating(Accounts::updateAccountStorages)); - } - private static Map getAccountStorage(Account account) { Map storage = account.toStorage(); storage.put("type", getLoginType(getAccountFactory(account))); - if (account == selectedAccount.get()) { - storage.put("selected", true); - } return storage; } @@ -160,7 +143,51 @@ private static void updateAccountStorages() { if (!initialized) return; // update storage - config().getAccountStorages().setAll(accounts.stream().map(Accounts::getAccountStorage).collect(toList())); + + ArrayList> global = new ArrayList<>(); + ArrayList> portable = new ArrayList<>(); + + for (Account account : accounts) { + Map storage = getAccountStorage(account); + if (account.isPortable()) + portable.add(storage); + else + global.add(storage); + } + + if (!global.equals(globalAccountStorages)) + globalAccountStorages.setAll(global); + if (!portable.equals(config().getAccountStorages())) + config().getAccountStorages().setAll(portable); + } + + private static void loadGlobalAccountStorages() { + Path globalAccountsFile = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("accounts.json"); + if (Files.exists(globalAccountsFile)) { + try (Reader reader = Files.newBufferedReader(globalAccountsFile)) { + globalAccountStorages.setAll(Config.CONFIG_GSON.fromJson(reader, listTypeOf(mapTypeOf(Object.class, Object.class)))); + } catch (Throwable e) { + LOG.warning("Failed to load global accounts", e); + } + } + + globalAccountStorages.addListener(onInvalidating(() -> + FileSaver.save(globalAccountsFile, Config.CONFIG_GSON.toJson(globalAccountStorages)))); + } + + private static Account parseAccount(Map storage) { + AccountFactory factory = type2factory.get(storage.get("type")); + if (factory == null) { + LOG.warning("Unrecognized account type: " + storage); + return null; + } + + try { + return factory.fromStorage(storage); + } catch (Exception e) { + LOG.warning("Failed to load account: " + storage, e); + return null; + } } /** @@ -170,38 +197,131 @@ static void init() { if (initialized) throw new IllegalStateException("Already initialized"); + if (!config().isAddedLittleSkin()) { + AuthlibInjectorServer littleSkin = new AuthlibInjectorServer("https://littleskin.cn/api/yggdrasil/"); + + if (config().getAuthlibInjectorServers().stream().noneMatch(it -> littleSkin.getUrl().equals(it.getUrl()))) { + config().getAuthlibInjectorServers().add(0, littleSkin); + } + + config().setAddedLittleSkin(true); + } + + loadGlobalAccountStorages(); + // load accounts - config().getAccountStorages().forEach(storage -> { - AccountFactory factory = type2factory.get(storage.get("type")); - if (factory == null) { - LOG.warning("Unrecognized account type: " + storage); - return; + Account selected = null; + for (Map storage : config().getAccountStorages()) { + Account account = parseAccount(storage); + if (account != null) { + account.setPortable(true); + accounts.add(account); + if (Boolean.TRUE.equals(storage.get("selected"))) { + selected = account; + } } - Account account; - try { - account = factory.fromStorage(storage); - } catch (Exception e) { - LOG.log(Level.WARNING, "Failed to load account: " + storage, e); - return; + } + + for (Map storage : globalAccountStorages) { + Account account = parseAccount(storage); + if (account != null) { + accounts.add(account); } - accounts.add(account); + } - if (Boolean.TRUE.equals(storage.get("selected"))) { - selectedAccount.set(account); + String selectedAccountIdentifier = config().getSelectedAccount(); + if (selected == null && selectedAccountIdentifier != null) { + boolean portable = true; + if (selectedAccountIdentifier.startsWith(GLOBAL_PREFIX)) { + portable = false; + selectedAccountIdentifier = selectedAccountIdentifier.substring(GLOBAL_PREFIX.length()); + } + + for (Account account : accounts) { + if (selectedAccountIdentifier.equals(account.getIdentifier())) { + if (portable == account.isPortable()) { + selected = account; + break; + } else if (selected == null) { + selected = account; + } + } + } + } + + if (selected == null && !accounts.isEmpty()) { + selected = accounts.get(0); + } + + if (!globalConfig().isEnableOfflineAccount()) + for (Account account : accounts) { + if (account instanceof MicrosoftAccount) { + globalConfig().setEnableOfflineAccount(true); + break; + } + } + + if (!globalConfig().isEnableOfflineAccount()) + accounts.addListener(new ListChangeListener() { + @Override + public void onChanged(Change change) { + while (change.next()) { + for (Account account : change.getAddedSubList()) { + if (account instanceof MicrosoftAccount) { + accounts.removeListener(this); + globalConfig().setEnableOfflineAccount(true); + return; + } + } + } + } + }); + + selectedAccount.set(selected); + + InvalidationListener listener = o -> { + // this method first checks whether the current selection is valid + // if it's valid, the underlying storage will be updated + // otherwise, the first account will be selected as an alternative(or null if accounts is empty) + Account account = selectedAccount.get(); + if (accounts.isEmpty()) { + if (account == null) { + // valid + } else { + // the previously selected account is gone, we can only set it to null here + selectedAccount.set(null); + } + } else { + if (accounts.contains(account)) { + // valid + } else { + // the previously selected account is gone + selectedAccount.set(accounts.get(0)); + } } - }); + }; + selectedAccount.addListener(listener); + selectedAccount.addListener(onInvalidating(() -> { + Account account = selectedAccount.get(); + if (account != null) + config().setSelectedAccount(account.isPortable() ? account.getIdentifier() : GLOBAL_PREFIX + account.getIdentifier()); + else + config().setSelectedAccount(null); + })); + accounts.addListener(listener); + accounts.addListener(onInvalidating(Accounts::updateAccountStorages)); initialized = true; config().getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts)); - Account selected = selectedAccount.get(); if (selected != null) { - Schedulers.io().schedule(() -> { + Account finalSelected = selected; + Schedulers.io().execute(() -> { try { - selected.logIn(); - } catch (AuthenticationException e) { - LOG.log(Level.WARNING, "Failed to log " + selected + " in", e); + finalSelected.logIn(); + } catch (Throwable e) { + LOG.warning("Failed to log " + finalSelected + " in", e); } }); } @@ -209,11 +329,11 @@ static void init() { for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) { if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server) continue; - Schedulers.io().schedule(() -> { + Schedulers.io().execute(() -> { try { server.fetchMetadataResponse(); } catch (IOException e) { - LOG.log(Level.WARNING, "Failed to fetch authlib-injector server metdata: " + server, e); + LOG.warning("Failed to fetch authlib-injector server metadata: " + server, e); } }); } @@ -223,10 +343,6 @@ public static ObservableList getAccounts() { return accounts; } - public static ReadOnlyListProperty accountsProperty() { - return accountsWrapper.getReadOnlyProperty(); - } - public static Account getSelectedAccount() { return selectedAccount.get(); } @@ -242,12 +358,18 @@ public static ObjectProperty selectedAccountProperty() { // ==== authlib-injector ==== private static AuthlibInjectorArtifactProvider createAuthlibInjectorArtifactProvider() { String authlibinjectorLocation = System.getProperty("hmcl.authlibinjector.location"); - if (authlibinjectorLocation == null) { - return new AuthlibInjectorDownloader(Metadata.HMCL_DIRECTORY, DownloadProviders::getDownloadProvider); - } else { + if (authlibinjectorLocation != null) { LOG.info("Using specified authlib-injector: " + authlibinjectorLocation); return new SimpleAuthlibInjectorArtifactProvider(Paths.get(authlibinjectorLocation)); } + + String authlibInjectorVersion = JarUtils.getAttribute("hmcl.authlib-injector.version", null); + if (authlibInjectorVersion == null) + throw new AssertionError("Missing hmcl.authlib-injector.version"); + + String authlibInjectorFileName = "authlib-injector-" + authlibInjectorVersion + ".jar"; + return new AuthlibInjectorExtractor(Accounts.class.getResource("/assets/" + authlibInjectorFileName), + Metadata.DEPENDENCIES_DIRECTORY.resolve("universal").resolve(authlibInjectorFileName)); } private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) { @@ -276,14 +398,82 @@ private static void removeDanglingAuthlibInjectorAccounts() { // ==== // ==== Login type name i18n === - private static Map, String> unlocalizedLoginTypeNames = mapOf( + private static final Map, String> unlocalizedLoginTypeNames = mapOf( pair(Accounts.FACTORY_OFFLINE, "account.methods.offline"), - pair(Accounts.FACTORY_MOJANG, "account.methods.yggdrasil"), - pair(Accounts.FACTORY_AUTHLIB_INJECTOR, "account.methods.authlib_injector")); + pair(Accounts.FACTORY_AUTHLIB_INJECTOR, "account.methods.authlib_injector"), + pair(Accounts.FACTORY_MICROSOFT, "account.methods.microsoft")); public static String getLocalizedLoginTypeName(AccountFactory factory) { return i18n(Optional.ofNullable(unlocalizedLoginTypeNames.get(factory)) .orElseThrow(() -> new IllegalArgumentException("Unrecognized account factory"))); } // ==== + + public static String localizeErrorMessage(Exception exception) { + if (exception instanceof NoCharacterException) { + return i18n("account.failed.no_character"); + } else if (exception instanceof ServerDisconnectException) { + if (exception.getCause() instanceof SSLException) { + return i18n("account.failed.ssl"); + } else { + return i18n("account.failed.connect_authentication_server"); + } + } else if (exception instanceof ServerResponseMalformedException) { + return i18n("account.failed.server_response_malformed"); + } else if (exception instanceof RemoteAuthenticationException) { + RemoteAuthenticationException remoteException = (RemoteAuthenticationException) exception; + String remoteMessage = remoteException.getRemoteMessage(); + if ("ForbiddenOperationException".equals(remoteException.getRemoteName()) && remoteMessage != null) { + if (remoteMessage.contains("Invalid credentials")) { + return i18n("account.failed.invalid_credentials"); + } else if (remoteMessage.contains("Invalid token")) { + return i18n("account.failed.invalid_token"); + } else if (remoteMessage.contains("Invalid username or password")) { + return i18n("account.failed.invalid_password"); + } else { + return remoteMessage; + } + } else if ("ResourceException".equals(remoteException.getRemoteName()) && remoteMessage != null) { + if (remoteMessage.contains("The requested resource is no longer available")) { + return i18n("account.failed.migration"); + } else { + return remoteMessage; + } + } + return exception.getMessage(); + } else if (exception instanceof AuthlibInjectorDownloadException) { + return i18n("account.failed.injector_download_failure"); + } else if (exception instanceof CharacterDeletedException) { + return i18n("account.failed.character_deleted"); + } else if (exception instanceof InvalidSkinException) { + return i18n("account.skin.invalid_skin"); + } else if (exception instanceof MicrosoftService.XboxAuthorizationException) { + long errorCode = ((MicrosoftService.XboxAuthorizationException) exception).getErrorCode(); + if (errorCode == MicrosoftService.XboxAuthorizationException.ADD_FAMILY) { + return i18n("account.methods.microsoft.error.add_family"); + } else if (errorCode == MicrosoftService.XboxAuthorizationException.COUNTRY_UNAVAILABLE) { + return i18n("account.methods.microsoft.error.country_unavailable"); + } else if (errorCode == MicrosoftService.XboxAuthorizationException.MISSING_XBOX_ACCOUNT) { + return i18n("account.methods.microsoft.error.missing_xbox_account"); + } else if (errorCode == MicrosoftService.XboxAuthorizationException.BANNED) { + return i18n("account.methods.microsoft.error.banned"); + } else { + return i18n("account.methods.microsoft.error.unknown", errorCode); + } + } else if (exception instanceof MicrosoftService.XBox400Exception) { + return i18n("account.methods.microsoft.error.wrong_verify_method"); + } else if (exception instanceof MicrosoftService.NoMinecraftJavaEditionProfileException) { + return i18n("account.methods.microsoft.error.no_character"); + } else if (exception instanceof MicrosoftService.NoXuiException) { + return i18n("account.methods.microsoft.error.add_family_probably"); + } else if (exception instanceof OAuthServer.MicrosoftAuthenticationNotSupportedException) { + return i18n("account.methods.microsoft.snapshot"); + } else if (exception instanceof OAuthAccount.WrongAccountException) { + return i18n("account.failed.wrong_account"); + } else if (exception.getClass() == AuthenticationException.class) { + return exception.getLocalizedMessage(); + } else { + return exception.getClass().getName() + ": " + exception.getLocalizedMessage(); + } + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/AuthlibInjectorServers.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/AuthlibInjectorServers.java new file mode 100644 index 0000000000..e5e7f6edce --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/AuthlibInjectorServers.java @@ -0,0 +1,96 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.gson.TolerableValidationException; +import org.jackhuang.hmcl.util.gson.Validation; +import org.jackhuang.hmcl.util.io.JarUtils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +@JsonSerializable +public final class AuthlibInjectorServers implements Validation { + + public static final String CONFIG_FILENAME = "authlib-injectors.json"; + + private static final Set servers = new CopyOnWriteArraySet<>(); + + public static Set getServers() { + return servers; + } + + private final List urls; + + private AuthlibInjectorServers(List urls) { + this.urls = urls; + } + + @Override + public void validate() throws JsonParseException, TolerableValidationException { + if (this.urls == null) { + throw new JsonParseException("authlib-injectors.json -> urls cannot be null."); + } + } + + public static void init() { + Path configLocation; + Path jarPath = JarUtils.thisJarPath(); + if (jarPath != null && Files.isRegularFile(jarPath) && Files.isWritable(jarPath)) { + configLocation = jarPath.getParent().resolve(CONFIG_FILENAME); + } else { + configLocation = Paths.get(CONFIG_FILENAME); + } + + if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) { + AuthlibInjectorServers configInstance; + try { + configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class); + } catch (IOException | JsonParseException e) { + LOG.warning("Malformed authlib-injectors.json", e); + return; + } + + if (!configInstance.urls.isEmpty()) { + config().setPreferredLoginType(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR)); + for (String url : configInstance.urls) { + Task.supplyAsync(Schedulers.io(), () -> AuthlibInjectorServer.locateServer(url)) + .thenAcceptAsync(Schedulers.javafx(), server -> { + config().getAuthlibInjectorServers().add(server); + servers.add(server); + }) + .start(); + } + } + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java index eb19beec3e..24e8acfd6a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,9 +17,8 @@ */ package org.jackhuang.hmcl.setting; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonParseException; +import com.google.gson.*; +import com.google.gson.annotations.JsonAdapter; import com.google.gson.annotations.SerializedName; import javafx.beans.InvalidationListener; import javafx.beans.Observable; @@ -28,173 +27,382 @@ import javafx.collections.ObservableList; import javafx.collections.ObservableMap; import javafx.collections.ObservableSet; +import javafx.scene.paint.Paint; import org.hildan.fxgson.creators.ObservableListCreator; import org.hildan.fxgson.creators.ObservableMapCreator; import org.hildan.fxgson.creators.ObservableSetCreator; import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; -import org.jackhuang.hmcl.upgrade.UpdateChannel; -import org.jackhuang.hmcl.util.gson.EnumOrdinalDeserializer; -import org.jackhuang.hmcl.util.gson.FileTypeAdapter; -import org.jackhuang.hmcl.util.i18n.Locales; -import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale; +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.gson.*; +import org.jackhuang.hmcl.util.i18n.SupportedLocale; +import org.jackhuang.hmcl.util.javafx.DirtyTracker; import org.jackhuang.hmcl.util.javafx.ObservableHelper; -import org.jackhuang.hmcl.util.javafx.PropertyUtils; import org.jetbrains.annotations.Nullable; -import java.io.File; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.*; import java.net.Proxy; -import java.util.Map; -import java.util.TreeMap; +import java.nio.file.Path; +import java.util.*; -public final class Config implements Cloneable, Observable { +@JsonAdapter(value = Config.Adapter.class) +public final class Config implements Observable { + public static final int CURRENT_VERSION = 2; public static final int CURRENT_UI_VERSION = 0; - private static final Gson CONFIG_GSON = new GsonBuilder() - .registerTypeAdapter(File.class, FileTypeAdapter.INSTANCE) + public static final Gson CONFIG_GSON = new GsonBuilder() + .registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE) .registerTypeAdapter(ObservableList.class, new ObservableListCreator()) .registerTypeAdapter(ObservableSet.class, new ObservableSetCreator()) .registerTypeAdapter(ObservableMap.class, new ObservableMapCreator()) .registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true)) .registerTypeAdapter(EnumBackgroundImage.class, new EnumOrdinalDeserializer<>(EnumBackgroundImage.class)) // backward compatibility for backgroundType .registerTypeAdapter(Proxy.Type.class, new EnumOrdinalDeserializer<>(Proxy.Type.class)) // backward compatibility for hasProxy + .registerTypeAdapter(Paint.class, new PaintAdapter()) .setPrettyPrinting() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) .create(); + private static final List> FIELDS; + + static { + final MethodHandles.Lookup lookup = MethodHandles.lookup(); + Field[] fields = Config.class.getDeclaredFields(); + + var configFields = new ArrayList>(fields.length); + for (Field field : fields) { + int modifiers = field.getModifiers(); + if (Modifier.isTransient(modifiers) || Modifier.isStatic(modifiers)) + continue; + + configFields.add(ObservableField.of(lookup, field)); + } + FIELDS = List.copyOf(configFields); + } + @Nullable public static Config fromJson(String json) throws JsonParseException { - Config loaded = CONFIG_GSON.fromJson(json, Config.class); - if (loaded == null) { - return null; + return CONFIG_GSON.fromJson(json, Config.class); + } + + private transient final ObservableHelper helper = new ObservableHelper(this); + private transient final DirtyTracker tracker = new DirtyTracker(); + private transient final Map unknownFields = new HashMap<>(); + + public Config() { + var shouldBeWrite = Collections.newSetFromMap(new IdentityHashMap<>()); + Collections.addAll(shouldBeWrite, configVersion, uiVersion); + + for (var field : FIELDS) { + Observable observable = field.get(this); + if (shouldBeWrite.contains(observable)) + tracker.markDirty(observable); + else + tracker.track(observable); + observable.addListener(helper); } - Config instance = new Config(); - PropertyUtils.copyProperties(loaded, instance); - return instance; } - @SerializedName("last") - private StringProperty selectedProfile = new SimpleStringProperty(""); + @Override + public void addListener(InvalidationListener listener) { + helper.addListener(listener); + } - @SerializedName("backgroundType") - private ObjectProperty backgroundImageType = new SimpleObjectProperty<>(EnumBackgroundImage.DEFAULT); + @Override + public void removeListener(InvalidationListener listener) { + helper.removeListener(listener); + } - @SerializedName("bgpath") - private StringProperty backgroundImage = new SimpleStringProperty(); + public String toJson() { + return CONFIG_GSON.toJson(this); + } + + // Properties + + @SerializedName("_version") + private final IntegerProperty configVersion = new SimpleIntegerProperty(CURRENT_VERSION); + + public IntegerProperty configVersionProperty() { + return configVersion; + } + + public int getConfigVersion() { + return configVersion.get(); + } + + public void setConfigVersion(int configVersion) { + this.configVersion.set(configVersion); + } + + /** + * The version of UI that the user have last used. + * If there is a major change in UI, {@link Config#CURRENT_UI_VERSION} should be increased. + * When {@link #CURRENT_UI_VERSION} is higher than the property, the user guide should be shown, + * then this property is set to the same value as {@link #CURRENT_UI_VERSION}. + * In particular, the property is default to 0, so that whoever open the application for the first time will see the guide. + */ + @SerializedName("uiVersion") + private final IntegerProperty uiVersion = new SimpleIntegerProperty(CURRENT_UI_VERSION); + + public IntegerProperty uiVersionProperty() { + return uiVersion; + } + + public int getUiVersion() { + return uiVersion.get(); + } + + public void setUiVersion(int uiVersion) { + this.uiVersion.set(uiVersion); + } + + @SerializedName("x") + private final DoubleProperty x = new SimpleDoubleProperty(); + + public DoubleProperty xProperty() { + return x; + } + + public double getX() { + return x.get(); + } + + public void setX(double x) { + this.x.set(x); + } + + @SerializedName("y") + private final DoubleProperty y = new SimpleDoubleProperty(); + + public DoubleProperty yProperty() { + return y; + } + + public double getY() { + return y.get(); + } + + public void setY(double y) { + this.y.set(y); + } + + @SerializedName("width") + private final DoubleProperty width = new SimpleDoubleProperty(); + + public DoubleProperty widthProperty() { + return width; + } + + public double getWidth() { + return width.get(); + } + + public void setWidth(double width) { + this.width.set(width); + } + + @SerializedName("height") + private final DoubleProperty height = new SimpleDoubleProperty(); + + public DoubleProperty heightProperty() { + return height; + } + + public double getHeight() { + return height.get(); + } + + public void setHeight(double height) { + this.height.set(height); + } + + @SerializedName("localization") + private final ObjectProperty localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT); + + public ObjectProperty localizationProperty() { + return localization; + } + + public SupportedLocale getLocalization() { + return localization.get(); + } + + public void setLocalization(SupportedLocale localization) { + this.localization.set(localization); + } + + @SerializedName("promptedVersion") + private final StringProperty promptedVersion = new SimpleStringProperty(); + + public String getPromptedVersion() { + return promptedVersion.get(); + } + + public StringProperty promptedVersionProperty() { + return promptedVersion; + } + + public void setPromptedVersion(String promptedVersion) { + this.promptedVersion.set(promptedVersion); + } + + @SerializedName("shownTips") + private final ObservableMap shownTips = FXCollections.observableHashMap(); + + public ObservableMap getShownTips() { + return shownTips; + } @SerializedName("commonDirType") - private ObjectProperty commonDirType = new SimpleObjectProperty<>(EnumCommonDirectory.DEFAULT); + private final ObjectProperty commonDirType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT); + + public ObjectProperty commonDirTypeProperty() { + return commonDirType; + } + + public EnumCommonDirectory getCommonDirType() { + return commonDirType.get(); + } + + public void setCommonDirType(EnumCommonDirectory commonDirType) { + this.commonDirType.set(commonDirType); + } @SerializedName("commonpath") - private StringProperty commonDirectory = new SimpleStringProperty(Metadata.MINECRAFT_DIRECTORY.toString()); + private final StringProperty commonDirectory = new SimpleStringProperty(Metadata.MINECRAFT_DIRECTORY.toString()); - @SerializedName("hasProxy") - private BooleanProperty hasProxy = new SimpleBooleanProperty(); + public StringProperty commonDirectoryProperty() { + return commonDirectory; + } - @SerializedName("hasProxyAuth") - private BooleanProperty hasProxyAuth = new SimpleBooleanProperty(); + public String getCommonDirectory() { + return commonDirectory.get(); + } - @SerializedName("proxyType") - private ObjectProperty proxyType = new SimpleObjectProperty<>(Proxy.Type.HTTP); + public void setCommonDirectory(String commonDirectory) { + this.commonDirectory.set(commonDirectory); + } - @SerializedName("proxyHost") - private StringProperty proxyHost = new SimpleStringProperty(); + @SerializedName("logLines") + private final ObjectProperty logLines = new SimpleObjectProperty<>(); - @SerializedName("proxyPort") - private IntegerProperty proxyPort = new SimpleIntegerProperty(); + public ObjectProperty logLinesProperty() { + return logLines; + } - @SerializedName("proxyUserName") - private StringProperty proxyUser = new SimpleStringProperty(); + public Integer getLogLines() { + return logLines.get(); + } - @SerializedName("proxyPassword") - private StringProperty proxyPass = new SimpleStringProperty(); + public void setLogLines(Integer logLines) { + this.logLines.set(logLines); + } - @SerializedName("theme") - private ObjectProperty theme = new SimpleObjectProperty<>(Theme.BLUE); + // UI - @SerializedName("localization") - private ObjectProperty localization = new SimpleObjectProperty<>(Locales.DEFAULT); + @SerializedName("theme") + private final ObjectProperty theme = new SimpleObjectProperty<>(); - @SerializedName("downloadType") - private StringProperty downloadType = new SimpleStringProperty("bmclapi"); + public ObjectProperty themeProperty() { + return theme; + } - @SerializedName("configurations") - private ObservableMap configurations = FXCollections.observableMap(new TreeMap<>()); + public Theme getTheme() { + return theme.get(); + } - @SerializedName("accounts") - private ObservableList> accountStorages = FXCollections.observableArrayList(); + public void setTheme(Theme theme) { + this.theme.set(theme); + } @SerializedName("fontFamily") - private StringProperty fontFamily = new SimpleStringProperty("Consolas"); + private final StringProperty fontFamily = new SimpleStringProperty(); - @SerializedName("fontSize") - private DoubleProperty fontSize = new SimpleDoubleProperty(12); + public StringProperty fontFamilyProperty() { + return fontFamily; + } - @SerializedName("logLines") - private IntegerProperty logLines = new SimpleIntegerProperty(100); + public String getFontFamily() { + return fontFamily.get(); + } - @SerializedName("authlibInjectorServers") - private ObservableList authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[] { server }); + public void setFontFamily(String fontFamily) { + this.fontFamily.set(fontFamily); + } - @SerializedName("updateChannel") - private ObjectProperty updateChannel = new SimpleObjectProperty<>(UpdateChannel.STABLE); + @SerializedName("fontSize") + private final DoubleProperty fontSize = new SimpleDoubleProperty(12); - @SerializedName("_version") - private IntegerProperty configVersion = new SimpleIntegerProperty(0); + public DoubleProperty fontSizeProperty() { + return fontSize; + } - /** - * The version of UI that the user have last used. - * If there is a major change in UI, {@link Config#CURRENT_UI_VERSION} should be increased. - * When {@link #CURRENT_UI_VERSION} is higher than the property, the user guide should be shown, - * then this property is set to the same value as {@link #CURRENT_UI_VERSION}. - * In particular, the property is default to 0, so that whoever open the application for the first time will see the guide. - */ - @SerializedName("uiVersion") - private IntegerProperty uiVersion = new SimpleIntegerProperty(0); + public double getFontSize() { + return fontSize.get(); + } - /** - * The preferred login type to use when the user wants to add an account. - */ - @SerializedName("preferredLoginType") - private StringProperty preferredLoginType = new SimpleStringProperty(); + public void setFontSize(double fontSize) { + this.fontSize.set(fontSize); + } - private transient ObservableHelper helper = new ObservableHelper(this); + @SerializedName("launcherFontFamily") + private final StringProperty launcherFontFamily = new SimpleStringProperty(); - public Config() { - PropertyUtils.attachListener(this, helper); + public StringProperty launcherFontFamilyProperty() { + return launcherFontFamily; } - @Override - public void addListener(InvalidationListener listener) { - helper.addListener(listener); + public String getLauncherFontFamily() { + return launcherFontFamily.get(); } - @Override - public void removeListener(InvalidationListener listener) { - helper.removeListener(listener); + public void setLauncherFontFamily(String launcherFontFamily) { + this.launcherFontFamily.set(launcherFontFamily); } - public String toJson() { - return CONFIG_GSON.toJson(this); + @SerializedName("animationDisabled") + private final BooleanProperty animationDisabled = new SimpleBooleanProperty( + FXUtils.REDUCED_MOTION == Boolean.TRUE + || !JavaRuntime.CURRENT_JIT_ENABLED + || !FXUtils.GPU_ACCELERATION_ENABLED + ); + + public BooleanProperty animationDisabledProperty() { + return animationDisabled; } - @Override - public Config clone() { - return fromJson(this.toJson()); + public boolean isAnimationDisabled() { + return animationDisabled.get(); } - // Getters & Setters & Properties - public String getSelectedProfile() { - return selectedProfile.get(); + public void setAnimationDisabled(boolean animationDisabled) { + this.animationDisabled.set(animationDisabled); } - public void setSelectedProfile(String selectedProfile) { - this.selectedProfile.set(selectedProfile); + @SerializedName("titleTransparent") + private final BooleanProperty titleTransparent = new SimpleBooleanProperty(false); + + public BooleanProperty titleTransparentProperty() { + return titleTransparent; } - public StringProperty selectedProfileProperty() { - return selectedProfile; + public boolean isTitleTransparent() { + return titleTransparent.get(); + } + + public void setTitleTransparent(boolean titleTransparent) { + this.titleTransparent.set(titleTransparent); + } + + @SerializedName("backgroundType") + private final ObjectProperty backgroundImageType = new RawPreservingObjectProperty<>(EnumBackgroundImage.DEFAULT); + + public ObjectProperty backgroundImageTypeProperty() { + return backgroundImageType; } public EnumBackgroundImage getBackgroundImageType() { @@ -205,8 +413,11 @@ public void setBackgroundImageType(EnumBackgroundImage backgroundImageType) { this.backgroundImageType.set(backgroundImageType); } - public ObjectProperty backgroundImageTypeProperty() { - return backgroundImageType; + @SerializedName("bgpath") + private final StringProperty backgroundImage = new SimpleStringProperty(); + + public StringProperty backgroundImageProperty() { + return backgroundImage; } public String getBackgroundImage() { @@ -217,32 +428,133 @@ public void setBackgroundImage(String backgroundImage) { this.backgroundImage.set(backgroundImage); } - public StringProperty backgroundImageProperty() { - return backgroundImage; + @SerializedName("bgurl") + private final StringProperty backgroundImageUrl = new SimpleStringProperty(); + + public StringProperty backgroundImageUrlProperty() { + return backgroundImageUrl; } - public EnumCommonDirectory getCommonDirType() { - return commonDirType.get(); + public String getBackgroundImageUrl() { + return backgroundImageUrl.get(); } - public ObjectProperty commonDirTypeProperty() { - return commonDirType; + public void setBackgroundImageUrl(String backgroundImageUrl) { + this.backgroundImageUrl.set(backgroundImageUrl); } - public void setCommonDirType(EnumCommonDirectory commonDirType) { - this.commonDirType.set(commonDirType); + @SerializedName("bgpaint") + private final ObjectProperty backgroundPaint = new SimpleObjectProperty<>(); + + public Paint getBackgroundPaint() { + return backgroundPaint.get(); } - public String getCommonDirectory() { - return commonDirectory.get(); + public ObjectProperty backgroundPaintProperty() { + return backgroundPaint; } - public void setCommonDirectory(String commonDirectory) { - this.commonDirectory.set(commonDirectory); + public void setBackgroundPaint(Paint backgroundPaint) { + this.backgroundPaint.set(backgroundPaint); } - public StringProperty commonDirectoryProperty() { - return commonDirectory; + @SerializedName("bgImageOpacity") + private final IntegerProperty backgroundImageOpacity = new SimpleIntegerProperty(100); + + public IntegerProperty backgroundImageOpacityProperty() { + return backgroundImageOpacity; + } + + public int getBackgroundImageOpacity() { + return backgroundImageOpacity.get(); + } + + public void setBackgroundImageOpacity(int backgroundImageOpacity) { + this.backgroundImageOpacity.set(backgroundImageOpacity); + } + + // Networks + + @SerializedName("autoDownloadThreads") + private final BooleanProperty autoDownloadThreads = new SimpleBooleanProperty(true); + + public BooleanProperty autoDownloadThreadsProperty() { + return autoDownloadThreads; + } + + public boolean getAutoDownloadThreads() { + return autoDownloadThreads.get(); + } + + public void setAutoDownloadThreads(boolean autoDownloadThreads) { + this.autoDownloadThreads.set(autoDownloadThreads); + } + + @SerializedName("downloadThreads") + private final IntegerProperty downloadThreads = new SimpleIntegerProperty(64); + + public IntegerProperty downloadThreadsProperty() { + return downloadThreads; + } + + public int getDownloadThreads() { + return downloadThreads.get(); + } + + public void setDownloadThreads(int downloadThreads) { + this.downloadThreads.set(downloadThreads); + } + + @SerializedName("downloadType") + private final StringProperty downloadType = new SimpleStringProperty(DownloadProviders.DEFAULT_RAW_PROVIDER_ID); + + public StringProperty downloadTypeProperty() { + return downloadType; + } + + public String getDownloadType() { + return downloadType.get(); + } + + public void setDownloadType(String downloadType) { + this.downloadType.set(downloadType); + } + + @SerializedName("autoChooseDownloadType") + private final BooleanProperty autoChooseDownloadType = new SimpleBooleanProperty(true); + + public BooleanProperty autoChooseDownloadTypeProperty() { + return autoChooseDownloadType; + } + + public boolean isAutoChooseDownloadType() { + return autoChooseDownloadType.get(); + } + + public void setAutoChooseDownloadType(boolean autoChooseDownloadType) { + this.autoChooseDownloadType.set(autoChooseDownloadType); + } + + @SerializedName("versionListSource") + private final StringProperty versionListSource = new SimpleStringProperty("balanced"); + + public StringProperty versionListSourceProperty() { + return versionListSource; + } + + public String getVersionListSource() { + return versionListSource.get(); + } + + public void setVersionListSource(String versionListSource) { + this.versionListSource.set(versionListSource); + } + + @SerializedName("hasProxy") + private final BooleanProperty hasProxy = new SimpleBooleanProperty(); + + public BooleanProperty hasProxyProperty() { + return hasProxy; } public boolean hasProxy() { @@ -253,8 +565,11 @@ public void setHasProxy(boolean hasProxy) { this.hasProxy.set(hasProxy); } - public BooleanProperty hasProxyProperty() { - return hasProxy; + @SerializedName("hasProxyAuth") + private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty(); + + public BooleanProperty hasProxyAuthProperty() { + return hasProxyAuth; } public boolean hasProxyAuth() { @@ -265,8 +580,11 @@ public void setHasProxyAuth(boolean hasProxyAuth) { this.hasProxyAuth.set(hasProxyAuth); } - public BooleanProperty hasProxyAuthProperty() { - return hasProxyAuth; + @SerializedName("proxyType") + private final ObjectProperty proxyType = new SimpleObjectProperty<>(Proxy.Type.HTTP); + + public ObjectProperty proxyTypeProperty() { + return proxyType; } public Proxy.Type getProxyType() { @@ -277,8 +595,11 @@ public void setProxyType(Proxy.Type proxyType) { this.proxyType.set(proxyType); } - public ObjectProperty proxyTypeProperty() { - return proxyType; + @SerializedName("proxyHost") + private final StringProperty proxyHost = new SimpleStringProperty(); + + public StringProperty proxyHostProperty() { + return proxyHost; } public String getProxyHost() { @@ -289,8 +610,11 @@ public void setProxyHost(String proxyHost) { this.proxyHost.set(proxyHost); } - public StringProperty proxyHostProperty() { - return proxyHost; + @SerializedName("proxyPort") + private final IntegerProperty proxyPort = new SimpleIntegerProperty(); + + public IntegerProperty proxyPortProperty() { + return proxyPort; } public int getProxyPort() { @@ -301,8 +625,11 @@ public void setProxyPort(int proxyPort) { this.proxyPort.set(proxyPort); } - public IntegerProperty proxyPortProperty() { - return proxyPort; + @SerializedName("proxyUserName") + private final StringProperty proxyUser = new SimpleStringProperty(); + + public StringProperty proxyUserProperty() { + return proxyUser; } public String getProxyUser() { @@ -313,8 +640,11 @@ public void setProxyUser(String proxyUser) { this.proxyUser.set(proxyUser); } - public StringProperty proxyUserProperty() { - return proxyUser; + @SerializedName("proxyPassword") + private final StringProperty proxyPass = new SimpleStringProperty(); + + public StringProperty proxyPassProperty() { + return proxyPass; } public String getProxyPass() { @@ -325,139 +655,158 @@ public void setProxyPass(String proxyPass) { this.proxyPass.set(proxyPass); } - public StringProperty proxyPassProperty() { - return proxyPass; - } - - public Theme getTheme() { - return theme.get(); - } + // Game - public void setTheme(Theme theme) { - this.theme.set(theme); - } + @SerializedName("disableAutoGameOptions") + private final BooleanProperty disableAutoGameOptions = new SimpleBooleanProperty(false); - public ObjectProperty themeProperty() { - return theme; + public BooleanProperty disableAutoGameOptionsProperty() { + return disableAutoGameOptions; } - public SupportedLocale getLocalization() { - return localization.get(); + public boolean isDisableAutoGameOptions() { + return disableAutoGameOptions.get(); } - public void setLocalization(SupportedLocale localization) { - this.localization.set(localization); + public void setDisableAutoGameOptions(boolean disableAutoGameOptions) { + this.disableAutoGameOptions.set(disableAutoGameOptions); } - public ObjectProperty localizationProperty() { - return localization; - } + // Accounts - public String getDownloadType() { - return downloadType.get(); - } + @SerializedName("authlibInjectorServers") + private final ObservableList authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[]{server}); - public void setDownloadType(String downloadType) { - this.downloadType.set(downloadType); + public ObservableList getAuthlibInjectorServers() { + return authlibInjectorServers; } - public StringProperty downloadTypeProperty() { - return downloadType; - } + @SerializedName("addedLittleSkin") + private final BooleanProperty addedLittleSkin = new SimpleBooleanProperty(false); - public ObservableMap getConfigurations() { - return configurations; + public BooleanProperty addedLittleSkinProperty() { + return addedLittleSkin; } - public ObservableList> getAccountStorages() { - return accountStorages; + public boolean isAddedLittleSkin() { + return addedLittleSkin.get(); } - public String getFontFamily() { - return fontFamily.get(); + public void setAddedLittleSkin(boolean addedLittleSkin) { + this.addedLittleSkin.set(addedLittleSkin); } - public void setFontFamily(String fontFamily) { - this.fontFamily.set(fontFamily); - } + /** + * The preferred login type to use when the user wants to add an account. + */ + @SerializedName("preferredLoginType") + private final StringProperty preferredLoginType = new SimpleStringProperty(); - public StringProperty fontFamilyProperty() { - return fontFamily; + public StringProperty preferredLoginTypeProperty() { + return preferredLoginType; } - public double getFontSize() { - return fontSize.get(); + public String getPreferredLoginType() { + return preferredLoginType.get(); } - public void setFontSize(double fontSize) { - this.fontSize.set(fontSize); + public void setPreferredLoginType(String preferredLoginType) { + this.preferredLoginType.set(preferredLoginType); } - public DoubleProperty fontSizeProperty() { - return fontSize; - } + @SerializedName("selectedAccount") + private final StringProperty selectedAccount = new SimpleStringProperty(); - public int getLogLines() { - return logLines.get(); + public StringProperty selectedAccountProperty() { + return selectedAccount; } - public void setLogLines(int logLines) { - this.logLines.set(logLines); + public String getSelectedAccount() { + return selectedAccount.get(); } - public IntegerProperty logLinesProperty() { - return logLines; + public void setSelectedAccount(String selectedAccount) { + this.selectedAccount.set(selectedAccount); } - public ObservableList getAuthlibInjectorServers() { - return authlibInjectorServers; - } + @SerializedName("accounts") + private final ObservableList> accountStorages = FXCollections.observableArrayList(); - public UpdateChannel getUpdateChannel() { - return updateChannel.get(); + public ObservableList> getAccountStorages() { + return accountStorages; } - public ObjectProperty updateChannelProperty() { - return updateChannel; - } + // Configurations - public void setUpdateChannel(UpdateChannel updateChannel) { - this.updateChannel.set(updateChannel); - } + @SerializedName("last") + private final StringProperty selectedProfile = new SimpleStringProperty(""); - public int getConfigVersion() { - return configVersion.get(); + public StringProperty selectedProfileProperty() { + return selectedProfile; } - public IntegerProperty configVersionProperty() { - return configVersion; + public String getSelectedProfile() { + return selectedProfile.get(); } - public void setConfigVersion(int configVersion) { - this.configVersion.set(configVersion); + public void setSelectedProfile(String selectedProfile) { + this.selectedProfile.set(selectedProfile); } - public int getUiVersion() { - return uiVersion.get(); - } + @SerializedName("configurations") + private final SimpleMapProperty configurations = new SimpleMapProperty<>(FXCollections.observableMap(new TreeMap<>())); - public IntegerProperty uiVersionProperty() { - return uiVersion; + public MapProperty getConfigurations() { + return configurations; } - public void setUiVersion(int uiVersion) { - this.uiVersion.set(uiVersion); - } + public static final class Adapter implements JsonSerializer, JsonDeserializer { - public String getPreferredLoginType() { - return preferredLoginType.get(); - } + @Override + public JsonElement serialize(Config config, Type typeOfSrc, JsonSerializationContext context) { + if (config == null) + return JsonNull.INSTANCE; - public void setPreferredLoginType(String preferredLoginType) { - this.preferredLoginType.set(preferredLoginType); - } + JsonObject result = new JsonObject(); + for (var field : FIELDS) { + Observable observable = field.get(config); + if (config.tracker.isDirty(observable)) { + field.serialize(result, config, context); + } + } + config.unknownFields.forEach(result::add); + return result; + } - public StringProperty preferredLoginTypeProperty() { - return preferredLoginType; + @Override + public Config deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + if (json == null || json.isJsonNull()) + return null; + + if (!json.isJsonObject()) + throw new JsonParseException("Config is not an object: " + json); + + Config config = new Config(); + + var values = new LinkedHashMap<>(json.getAsJsonObject().asMap()); + for (ObservableField field : FIELDS) { + JsonElement value = values.remove(field.getSerializedName()); + if (value == null) { + for (String alternateName : field.getAlternateNames()) { + value = values.remove(alternateName); + if (value != null) + break; + } + } + + if (value != null) { + config.tracker.markDirty(field.get(config)); + field.deserialize(config, value, context); + } + } + + config.unknownFields.putAll(values); + return config; + } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigHolder.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigHolder.java index 10d719fe46..2854207c33 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigHolder.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigHolder.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,33 +17,35 @@ */ package org.jackhuang.hmcl.setting; -import com.google.gson.Gson; import com.google.gson.JsonParseException; -import org.jackhuang.hmcl.util.InvocationDispatcher; -import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.util.FileSaver; +import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Map; -import java.util.logging.Level; +import java.nio.file.*; +import java.util.Locale; -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.jackhuang.hmcl.util.Logging.LOG; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class ConfigHolder { - private ConfigHolder() {} + private ConfigHolder() { + } public static final String CONFIG_FILENAME = "hmcl.json"; public static final String CONFIG_FILENAME_LINUX = ".hmcl.json"; + public static final Path GLOBAL_CONFIG_PATH = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("config.json"); private static Path configLocation; private static Config configInstance; + private static GlobalConfig globalConfigInstance; private static boolean newlyCreated; + private static boolean ownerChanged = false; + private static boolean unsupportedVersion = false; public static Config config() { if (configInstance == null) { @@ -52,38 +54,92 @@ public static Config config() { return configInstance; } - public synchronized static void init() throws IOException { + public static GlobalConfig globalConfig() { + if (globalConfigInstance == null) { + throw new IllegalStateException("Configuration hasn't been loaded"); + } + return globalConfigInstance; + } + + public static Path configLocation() { + return configLocation; + } + + public static boolean isNewlyCreated() { + return newlyCreated; + } + + public static boolean isOwnerChanged() { + return ownerChanged; + } + + public static boolean isUnsupportedVersion() { + return unsupportedVersion; + } + + public static void init() throws IOException { if (configInstance != null) { throw new IllegalStateException("Configuration is already loaded"); } configLocation = locateConfig(); + + LOG.info("Config location: " + configLocation); + configInstance = loadConfig(); - configInstance.addListener(source -> markConfigDirty()); + if (!unsupportedVersion) + configInstance.addListener(source -> FileSaver.save(configLocation, configInstance.toJson())); + + globalConfigInstance = loadGlobalConfig(); + globalConfigInstance.addListener(source -> FileSaver.save(GLOBAL_CONFIG_PATH, globalConfigInstance.toJson())); + Locale.setDefault(config().getLocalization().getLocale()); + I18n.setLocale(configInstance.getLocalization()); + LOG.setLogRetention(globalConfig().getLogRetention()); Settings.init(); if (newlyCreated) { - saveConfigSync(); - - // hide the config file on windows - if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { - try { - Files.setAttribute(configLocation, "dos:hidden", true); - } catch (IOException e) { - LOG.log(Level.WARNING, "Failed to set hidden attribute of " + configLocation, e); - } - } + LOG.info("Creating config file " + configLocation); + FileUtils.saveSafely(configLocation, configInstance.toJson()); } if (!Files.isWritable(configLocation)) { - // the config cannot be saved - // throw up the error now to prevent further data loss - throw new IOException("Config at " + configLocation + " is not writable"); + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS + && configLocation.getFileSystem() == FileSystems.getDefault() + && configLocation.toFile().canWrite()) { + LOG.warning("Config at " + configLocation + " is not writable, but it seems to be a Samba share or OpenJDK bug"); + // There are some serious problems with the implementation of Samba or OpenJDK + throw new SambaException(); + } else { + // the config cannot be saved + // throw up the error now to prevent further data loss + throw new IOException("Config at " + configLocation + " is not writable"); + } } } private static Path locateConfig() { + Path defaultConfigFile = Metadata.HMCL_CURRENT_DIRECTORY.resolve(CONFIG_FILENAME); + if (Files.isRegularFile(defaultConfigFile)) + return defaultConfigFile; + + try { + Path jarPath = JarUtils.thisJarPath(); + if (jarPath != null && Files.isRegularFile(jarPath) && Files.isWritable(jarPath)) { + jarPath = jarPath.getParent(); + + Path config = jarPath.resolve(CONFIG_FILENAME); + if (Files.isRegularFile(config)) + return config; + + Path dotConfig = jarPath.resolve(CONFIG_FILENAME_LINUX); + if (Files.isRegularFile(dotConfig)) + return dotConfig; + } + + } catch (Throwable ignore) { + } + Path config = Paths.get(CONFIG_FILENAME); if (Files.isRegularFile(config)) return config; @@ -93,51 +149,64 @@ private static Path locateConfig() { return dotConfig; // create new - return OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? config : dotConfig; + return defaultConfigFile; } private static Config loadConfig() throws IOException { if (Files.exists(configLocation)) { try { - String content = FileUtils.readText(configLocation); + if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS + && "root".equals(System.getProperty("user.name")) + && !"root".equals(Files.getOwner(configLocation).getName())) { + ownerChanged = true; + } + } catch (IOException e1) { + LOG.warning("Failed to get owner"); + } + try { + String content = Files.readString(configLocation); Config deserialized = Config.fromJson(content); if (deserialized == null) { LOG.info("Config is empty"); } else { - Map raw = new Gson().fromJson(content, Map.class); - ConfigUpgrader.upgradeConfig(deserialized, raw); + int configVersion = deserialized.getConfigVersion(); + if (configVersion < Config.CURRENT_VERSION) { + ConfigUpgrader.upgradeConfig(deserialized, content); + } else if (configVersion > Config.CURRENT_VERSION) { + unsupportedVersion = true; + LOG.warning(String.format("Current HMCL only support the configuration version up to %d. However, the version now is %d.", Config.CURRENT_VERSION, configVersion)); + } + return deserialized; } } catch (JsonParseException e) { - LOG.log(Level.WARNING, "Malformed config.", e); + LOG.warning("Malformed config.", e); } } - LOG.info("Creating an empty config"); newlyCreated = true; return new Config(); } - private static InvocationDispatcher configWriter = InvocationDispatcher.runOn(Lang::thread, content -> { - try { - writeToConfig(content); - } catch (IOException e) { - LOG.log(Level.SEVERE, "Failed to save config", e); - } - }); + // Global Config - private static void writeToConfig(String content) throws IOException { - LOG.info("Saving config"); - synchronized (configLocation) { - Files.write(configLocation, content.getBytes(UTF_8)); + private static GlobalConfig loadGlobalConfig() throws IOException { + if (Files.exists(GLOBAL_CONFIG_PATH)) { + try { + String content = Files.readString(GLOBAL_CONFIG_PATH); + GlobalConfig deserialized = GlobalConfig.fromJson(content); + if (deserialized == null) { + LOG.info("Config is empty"); + } else { + return deserialized; + } + } catch (JsonParseException e) { + LOG.warning("Malformed config.", e); + } } - } - static void markConfigDirty() { - configWriter.accept(configInstance.toJson()); + LOG.info("Creating an empty global config"); + return new GlobalConfig(); } - private static void saveConfigSync() throws IOException { - writeToConfig(configInstance.toJson()); - } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigUpgrader.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigUpgrader.java index 5f68b84dde..2eded388fd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigUpgrader.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigUpgrader.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,113 +17,80 @@ */ package org.jackhuang.hmcl.setting; +import com.google.gson.Gson; import org.jackhuang.hmcl.util.StringUtils; + +import java.util.Collections; import java.util.HashMap; import java.util.Map; import static org.jackhuang.hmcl.util.Lang.tryCast; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; final class ConfigUpgrader { - private static final int VERSION = 0; - private ConfigUpgrader() { } /** - * This method is for the compatibility with old HMCL 3.x as well as HMCL 2.x. + * This method is for the compatibility with old HMCL versions. * * @param deserialized deserialized config settings - * @param rawJson raw json structure of the config settings without modification - * @return true if config version is upgraded + * @param rawContent raw json content of the config settings without modification */ - static boolean upgradeConfig(Config deserialized, Map rawJson) { - boolean upgraded; - if (deserialized.getConfigVersion() < VERSION) { - deserialized.setConfigVersion(VERSION); - // TODO: Add upgrade code here. - upgraded = true; - } else { - upgraded = false; - } + static void upgradeConfig(Config deserialized, String rawContent) { + int configVersion = deserialized.getConfigVersion(); - upgradeV2(deserialized, rawJson); - upgradeV3(deserialized, rawJson); + if (configVersion >= Config.CURRENT_VERSION) + return; - return upgraded; - } + LOG.info(String.format("Updating configuration from %d to %d.", configVersion, Config.CURRENT_VERSION)); + Map rawJson = Collections.unmodifiableMap(new Gson().>fromJson(rawContent, Map.class)); - /** - * Upgrade configuration of HMCL 2.x - * - * @param deserialized deserialized config settings - * @param rawJson raw json structure of the config settings without modification - */ - private static void upgradeV2(Config deserialized, Map rawJson) { - // Convert OfflineAccounts whose stored uuid is important. - tryCast(rawJson.get("auth"), Map.class).ifPresent(auth -> { - tryCast(auth.get("offline"), Map.class).ifPresent(offline -> { - String selected = rawJson.containsKey("selectedAccount") ? null - : tryCast(offline.get("IAuthenticator_UserName"), String.class).orElse(null); + if (configVersion < 1) { + // Upgrade configuration of HMCL 2.x: Convert OfflineAccounts whose stored uuid is important. + tryCast(rawJson.get("auth"), Map.class).ifPresent(auth -> { + tryCast(auth.get("offline"), Map.class).ifPresent(offline -> { + String selected = rawJson.containsKey("selectedAccount") ? null + : tryCast(offline.get("IAuthenticator_UserName"), String.class).orElse(null); - tryCast(offline.get("uuidMap"), Map.class).ifPresent(uuidMap -> { - ((Map) uuidMap).forEach((key, value) -> { - Map storage = new HashMap<>(); - storage.put("type", "offline"); - storage.put("username", key); - storage.put("uuid", value); - if (key.equals(selected)) { - storage.put("selected", true); - } - deserialized.getAccountStorages().add(storage); + tryCast(offline.get("uuidMap"), Map.class).ifPresent(uuidMap -> { + ((Map) uuidMap).forEach((key, value) -> { + Map storage = new HashMap<>(); + storage.put("type", "offline"); + storage.put("username", key); + storage.put("uuid", value); + if (key.equals(selected)) { + storage.put("selected", true); + } + deserialized.getAccountStorages().add(storage); + }); }); }); }); - }); - } - /** - * Upgrade configuration of HMCL earlier than 3.1.70 - * - * @param deserialized deserialized config settings - * @param rawJson raw json structure of the config settings without modification - */ - private static void upgradeV3(Config deserialized, Map rawJson) { - if (!rawJson.containsKey("commonDirType")) - deserialized.setCommonDirType(deserialized.getCommonDirectory().equals(Settings.getDefaultCommonDirectory()) ? EnumCommonDirectory.DEFAULT : EnumCommonDirectory.CUSTOM); - if (!rawJson.containsKey("backgroundType")) - deserialized.setBackgroundImageType(StringUtils.isNotBlank(deserialized.getBackgroundImage()) ? EnumBackgroundImage.CUSTOM : EnumBackgroundImage.DEFAULT); - if (!rawJson.containsKey("hasProxy")) - deserialized.setHasProxy(StringUtils.isNotBlank(deserialized.getProxyHost())); - if (!rawJson.containsKey("hasProxyAuth")) - deserialized.setHasProxyAuth(StringUtils.isNotBlank(deserialized.getProxyUser())); + // Upgrade configuration of HMCL earlier than 3.1.70 + if (!rawJson.containsKey("commonDirType")) + deserialized.setCommonDirType(deserialized.getCommonDirectory().equals(Settings.getDefaultCommonDirectory()) ? EnumCommonDirectory.DEFAULT : EnumCommonDirectory.CUSTOM); + if (!rawJson.containsKey("backgroundType")) + deserialized.setBackgroundImageType(StringUtils.isNotBlank(deserialized.getBackgroundImage()) ? EnumBackgroundImage.CUSTOM : EnumBackgroundImage.DEFAULT); + if (!rawJson.containsKey("hasProxy")) + deserialized.setHasProxy(StringUtils.isNotBlank(deserialized.getProxyHost())); + if (!rawJson.containsKey("hasProxyAuth")) + deserialized.setHasProxyAuth(StringUtils.isNotBlank(deserialized.getProxyUser())); - if (!rawJson.containsKey("downloadType")) { - tryCast(rawJson.get("downloadtype"), Number.class) - .map(Number::intValue) - .ifPresent(id -> { - if (id == 0) { - deserialized.setDownloadType("mojang"); - } else if (id == 1) { - deserialized.setDownloadType("bmclapi"); - } - }); + if (!rawJson.containsKey("downloadType")) { + tryCast(rawJson.get("downloadtype"), Number.class) + .map(Number::intValue) + .ifPresent(id -> { + if (id == 0) { + deserialized.setDownloadType("mojang"); + } else if (id == 1) { + deserialized.setDownloadType("bmclapi"); + } + }); + } } - tryCast(rawJson.get("selectedAccount"), String.class) - .ifPresent(selected -> { - deserialized.getAccountStorages().stream() - .filter(storage -> { - Object type = storage.get("type"); - if ("offline".equals(type)) { - return selected.equals(storage.get("username") + ":" + storage.get("username")); - } else if ("yggdrasil".equals(type) || "authlibInjector".equals(type)) { - return selected.equals(storage.get("username") + ":" + storage.get("displayName")); - } else { - return false; - } - }) - .findFirst() - .ifPresent(storage -> storage.put("selected", true)); - }); + deserialized.setConfigVersion(Config.CURRENT_VERSION); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java index bd54b98b19..66b9973a71 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,44 +17,148 @@ */ package org.jackhuang.hmcl.setting; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.util.Lang.mapOf; -import static org.jackhuang.hmcl.util.Pair.pair; +import javafx.beans.InvalidationListener; +import org.jackhuang.hmcl.download.*; +import org.jackhuang.hmcl.task.DownloadException; +import org.jackhuang.hmcl.task.FetchTask; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.io.ResponseCodeException; +import javax.net.ssl.SSLHandshakeException; +import java.io.FileNotFoundException; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.nio.file.AccessDeniedException; +import java.util.ArrayList; +import java.util.List; import java.util.Map; -import java.util.Optional; - -import org.jackhuang.hmcl.download.BMCLAPIDownloadProvider; -import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.MojangDownloadProvider; +import java.util.Objects; +import java.util.concurrent.CancellationException; -import javafx.beans.binding.Bindings; -import javafx.beans.binding.ObjectBinding; -import javafx.beans.value.ObservableObjectValue; +import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class DownloadProviders { - private DownloadProviders() {} + private DownloadProviders() { + } + + private static final DownloadProviderWrapper provider; + + public static final Map providersById; + public static final Map rawProviders; + private static final AdaptedDownloadProvider fileDownloadProvider = new AdaptedDownloadProvider(); + + private static final MojangDownloadProvider MOJANG; + private static final BMCLAPIDownloadProvider BMCLAPI; + + public static final String DEFAULT_PROVIDER_ID = "balanced"; + public static final String DEFAULT_RAW_PROVIDER_ID = "bmclapi"; + + @SuppressWarnings("unused") + private static final InvalidationListener observer; - public static final Map providersById = mapOf( - pair("mojang", new MojangDownloadProvider()), - pair("bmclapi", new BMCLAPIDownloadProvider())); + static { + String bmclapiRoot = "https://bmclapi2.bangbang93.com"; + String bmclapiRootOverride = System.getProperty("hmcl.bmclapi.override"); + if (bmclapiRootOverride != null) bmclapiRoot = bmclapiRootOverride; - public static final String DEFAULT_PROVIDER_ID = "bmclapi"; + MOJANG = new MojangDownloadProvider(); + BMCLAPI = new BMCLAPIDownloadProvider(bmclapiRoot); + rawProviders = Map.of( + "mojang", MOJANG, + "bmclapi", BMCLAPI + ); - private static ObjectBinding downloadProviderProperty; + AdaptedDownloadProvider fileProvider = new AdaptedDownloadProvider(); + fileProvider.setDownloadProviderCandidates(List.of(BMCLAPI, MOJANG)); + BalancedDownloadProvider balanced = new BalancedDownloadProvider(MOJANG, BMCLAPI); + + providersById = Map.of( + "official", new AutoDownloadProvider(MOJANG, fileProvider), + "balanced", new AutoDownloadProvider(balanced, fileProvider), + "mirror", new AutoDownloadProvider(BMCLAPI, fileProvider)); + + observer = FXUtils.observeWeak(() -> { + FetchTask.setDownloadExecutorConcurrency( + config().getAutoDownloadThreads() ? DEFAULT_CONCURRENCY : config().getDownloadThreads()); + }, config().autoDownloadThreadsProperty(), config().downloadThreadsProperty()); + + provider = new DownloadProviderWrapper(MOJANG); + } static void init() { - downloadProviderProperty = Bindings.createObjectBinding( - () -> Optional.ofNullable(providersById.get(config().getDownloadType())) - .orElse(providersById.get(DEFAULT_PROVIDER_ID)), - config().downloadTypeProperty()); + InvalidationListener onChangeDownloadSource = observable -> { + String versionListSource = Objects.requireNonNullElse(config().getVersionListSource(), ""); + if (config().isAutoChooseDownloadType()) { + DownloadProvider currentDownloadProvider = providersById.get(versionListSource); + if (currentDownloadProvider == null) + currentDownloadProvider = Objects.requireNonNull(providersById.get(DEFAULT_PROVIDER_ID), + "default provider is null"); + + provider.setProvider(currentDownloadProvider); + } else { + provider.setProvider(fileDownloadProvider); + } + }; + config().versionListSourceProperty().addListener(onChangeDownloadSource); + config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource); + + onChangeDownloadSource.invalidated(null); + + FXUtils.onChangeAndOperate(config().downloadTypeProperty(), downloadType -> { + DownloadProvider primary = Objects.requireNonNullElseGet( + rawProviders.get(Objects.requireNonNullElse(downloadType, "")), + () -> rawProviders.get(DEFAULT_RAW_PROVIDER_ID)); + + List providers = new ArrayList<>(rawProviders.size()); + providers.add(primary); + for (DownloadProvider provider : rawProviders.values()) { + if (provider != primary) + providers.add(provider); + } + + fileDownloadProvider.setDownloadProviderCandidates(providers); + }); } + /** + * Get current primary preferred download provider + */ public static DownloadProvider getDownloadProvider() { - return downloadProviderProperty.get(); + return provider; } - public static ObservableObjectValue downloadProviderProperty() { - return downloadProviderProperty; + public static String localizeErrorMessage(Throwable exception) { + if (exception instanceof DownloadException) { + URI uri = ((DownloadException) exception).getUri(); + if (exception.getCause() instanceof SocketTimeoutException) { + return i18n("install.failed.downloading.timeout", uri); + } else if (exception.getCause() instanceof ResponseCodeException) { + ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause(); + if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) { + return i18n("download.code." + responseCodeException.getResponseCode(), uri); + } else { + return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()); + } + } else if (exception.getCause() instanceof FileNotFoundException) { + return i18n("download.code.404", uri); + } else if (exception.getCause() instanceof AccessDeniedException) { + return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile()); + } else if (exception.getCause() instanceof ArtifactMalformedException) { + return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed"); + } else if (exception.getCause() instanceof SSLHandshakeException) { + return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake"); + } else { + return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()); + } + } else if (exception instanceof ArtifactMalformedException) { + return i18n("exception.artifact_malformed"); + } else if (exception instanceof CancellationException) { + return i18n("message.cancelled"); + } + return StringUtils.getStackTrace(exception); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumBackgroundImage.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumBackgroundImage.java index 83da7f0744..64c071e7c2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumBackgroundImage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumBackgroundImage.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,5 +19,9 @@ public enum EnumBackgroundImage { DEFAULT, - CUSTOM + CUSTOM, + CLASSIC, + NETWORK, + TRANSLUCENT, // Deprecated + PAINT } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumCommonDirectory.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumCommonDirectory.java index f22951b88b..9d34be3caf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumCommonDirectory.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumCommonDirectory.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java new file mode 100644 index 0000000000..a73a60257b --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java @@ -0,0 +1,294 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.text.Font; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.util.Lazy; +import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.i18n.LocaleUtils; +import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.platform.SystemUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.MalformedURLException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/** + * @author Glavo + */ +public final class FontManager { + + public static final String[] FONT_EXTENSIONS = { + "ttf", "otf", "woff" + }; + + public static final double DEFAULT_FONT_SIZE = 12.0f; + + private static final Lazy DEFAULT_FONT = new Lazy<>(() -> { + Font font; + + // Recommended + + font = tryLoadLocalizedFont(Metadata.HMCL_CURRENT_DIRECTORY.resolve("font")); + if (font != null) + return font; + + font = tryLoadLocalizedFont(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("font")); + if (font != null) + return font; + + // Legacy + + font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY); + if (font != null) + return font; + + font = tryLoadDefaultFont(Metadata.CURRENT_DIRECTORY); + if (font != null) + return font; + + font = tryLoadDefaultFont(Metadata.HMCL_GLOBAL_DIRECTORY); + if (font != null) + return font; + + Path thisJar = JarUtils.thisJarPath(); + if (thisJar != null && thisJar.getParent() != null) { + font = tryLoadDefaultFont(thisJar.getParent()); + if (font != null) + return font; + } + + // Default + + String fcMatchPattern; + if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() + && !(fcMatchPattern = I18n.getLocale().getFcMatchPattern()).isEmpty()) + return findByFcMatch(fcMatchPattern); + else + return null; + }); + + private static final ObjectProperty fontProperty; + + static { + String fontFamily = config().getLauncherFontFamily(); + if (fontFamily == null) + fontFamily = System.getProperty("hmcl.font.override"); + if (fontFamily == null) + fontFamily = System.getenv("HMCL_FONT"); + + FontReference fontReference; + if (fontFamily == null) { + Font defaultFont = DEFAULT_FONT.get(); + fontReference = defaultFont != null ? new FontReference(defaultFont) : null; + } else + fontReference = new FontReference(fontFamily); + + fontProperty = new SimpleObjectProperty<>(fontReference); + + LOG.info("Font: " + (fontReference != null ? fontReference.getFamily() : "System")); + fontProperty.addListener((obs, oldValue, newValue) -> { + if (newValue != null) + config().setLauncherFontFamily(newValue.getFamily()); + else + config().setLauncherFontFamily(null); + }); + } + + private static Font tryLoadDefaultFont(Path dir) { + for (String extension : FONT_EXTENSIONS) { + Path path = dir.resolve("font." + extension); + if (Files.isRegularFile(path)) { + LOG.info("Load font file: " + path); + try { + Font font = Font.loadFont(path.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE); + if (font != null) { + return font; + } + } catch (MalformedURLException ignored) { + } + + LOG.warning("Failed to load font " + path); + } + } + + return null; + } + + private static Font tryLoadLocalizedFont(Path dir) { + Map> fontFiles = LocaleUtils.findAllLocalizedFiles(dir, "font", Set.of(FONT_EXTENSIONS)); + if (fontFiles.isEmpty()) + return null; + + List candidateLocales = I18n.getLocale().getCandidateLocales(); + + for (Locale locale : candidateLocales) { + Map extToFiles = fontFiles.get(LocaleUtils.toLanguageKey(locale)); + if (extToFiles != null) { + for (String ext : FONT_EXTENSIONS) { + Path fontFile = extToFiles.get(ext); + if (fontFile != null) { + LOG.info("Load font file: " + fontFile); + try { + Font font = Font.loadFont( + fontFile.toAbsolutePath().normalize().toUri().toURL().toExternalForm(), + DEFAULT_FONT_SIZE); + if (font != null) + return font; + } catch (MalformedURLException ignored) { + } + + LOG.warning("Failed to load font " + fontFile); + } + } + } + } + + return null; + } + + public static Font findByFcMatch(String pattern) { + Path fcMatch = SystemUtils.which("fc-match"); + if (fcMatch == null) + return null; + + try { + String result = SystemUtils.run(fcMatch.toString(), + pattern, + "--format", "%{family}\\n%{file}").trim(); + String[] results = result.split("\\n"); + if (results.length != 2 || results[0].isEmpty() || results[1].isEmpty()) { + LOG.warning("Unexpected output from fc-match: " + result); + return null; + } + + String family = results[0].trim(); + String path = results[1]; + + Path file = Paths.get(path).toAbsolutePath().normalize(); + if (!Files.isRegularFile(file)) { + LOG.warning("Font file does not exist: " + path); + return null; + } + + LOG.info("Load font file: " + path); + Font[] fonts = Font.loadFonts(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE); + if (fonts == null) { + LOG.warning("Failed to load font from " + path); + return null; + } else if (fonts.length == 0) { + LOG.warning("No fonts loaded from " + path); + return null; + } + + for (Font font : fonts) { + if (font.getFamily().equalsIgnoreCase(family)) { + return font; + } + } + + if (family.indexOf(',') >= 0) { + for (String candidateFamily : family.split(",")) { + for (Font font : fonts) { + if (font.getFamily().equalsIgnoreCase(candidateFamily)) { + return font; + } + } + } + } + + LOG.warning(String.format("Family '%s' not found in font file '%s'", family, path)); + return fonts[0]; + } catch (Throwable e) { + LOG.warning("Failed to get default font with fc-match", e); + return null; + } + } + + public static ObjectProperty fontProperty() { + return fontProperty; + } + + public static FontReference getFont() { + return fontProperty.get(); + } + + public static void setFont(FontReference font) { + fontProperty.set(font); + } + + public static void setFontFamily(String fontFamily) { + setFont(fontFamily != null ? new FontReference(fontFamily) : null); + } + + // https://github.com/HMCL-dev/HMCL/issues/4072 + public static final class FontReference { + private final @NotNull String family; + private final @Nullable String style; + + public FontReference(@NotNull String family) { + this.family = Objects.requireNonNull(family); + this.style = null; + } + + public FontReference(@NotNull Font font) { + this.family = font.getFamily(); + this.style = font.getStyle(); + } + + public @NotNull String getFamily() { + return family; + } + + public @Nullable String getStyle() { + return style; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof FontReference)) + return false; + FontReference that = (FontReference) o; + return Objects.equals(family, that.family) && Objects.equals(style, that.style); + } + + @Override + public int hashCode() { + return Objects.hash(family, style); + } + + @Override + public String toString() { + return String.format("FontReference[family='%s', style='%s']", family, style); + } + } + + private FontManager() { + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java new file mode 100644 index 0000000000..a852756aa3 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java @@ -0,0 +1,227 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.*; +import com.google.gson.annotations.JsonAdapter; +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.beans.property.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableSet; +import org.jackhuang.hmcl.util.javafx.ObservableHelper; +import org.jackhuang.hmcl.util.javafx.PropertyUtils; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Type; +import java.util.*; + +@JsonAdapter(GlobalConfig.Serializer.class) +public final class GlobalConfig implements Observable { + + @Nullable + public static GlobalConfig fromJson(String json) throws JsonParseException { + GlobalConfig loaded = Config.CONFIG_GSON.fromJson(json, GlobalConfig.class); + if (loaded == null) { + return null; + } + GlobalConfig instance = new GlobalConfig(); + PropertyUtils.copyProperties(loaded, instance); + instance.unknownFields.putAll(loaded.unknownFields); + return instance; + } + + private final IntegerProperty agreementVersion = new SimpleIntegerProperty(); + + private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty(); + + private final IntegerProperty logRetention = new SimpleIntegerProperty(); + + private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false); + + private final StringProperty fontAntiAliasing = new SimpleStringProperty(); + + private final ObservableSet userJava = FXCollections.observableSet(new LinkedHashSet<>()); + + private final ObservableSet disabledJava = FXCollections.observableSet(new LinkedHashSet<>()); + + private final Map unknownFields = new HashMap<>(); + + private final transient ObservableHelper helper = new ObservableHelper(this); + + public GlobalConfig() { + PropertyUtils.attachListener(this, helper); + } + + @Override + public void addListener(InvalidationListener listener) { + helper.addListener(listener); + } + + @Override + public void removeListener(InvalidationListener listener) { + helper.removeListener(listener); + } + + public String toJson() { + return Config.CONFIG_GSON.toJson(this); + } + + public int getAgreementVersion() { + return agreementVersion.get(); + } + + public IntegerProperty agreementVersionProperty() { + return agreementVersion; + } + + public void setAgreementVersion(int agreementVersion) { + this.agreementVersion.set(agreementVersion); + } + + public int getPlatformPromptVersion() { + return platformPromptVersion.get(); + } + + public IntegerProperty platformPromptVersionProperty() { + return platformPromptVersion; + } + + public void setPlatformPromptVersion(int platformPromptVersion) { + this.platformPromptVersion.set(platformPromptVersion); + } + + public int getLogRetention() { + return logRetention.get(); + } + + public IntegerProperty logRetentionProperty() { + return logRetention; + } + + public void setLogRetention(int logRetention) { + this.logRetention.set(logRetention); + } + + public boolean isEnableOfflineAccount() { + return enableOfflineAccount.get(); + } + + public BooleanProperty enableOfflineAccountProperty() { + return enableOfflineAccount; + } + + public void setEnableOfflineAccount(boolean value) { + enableOfflineAccount.set(value); + } + + public StringProperty fontAntiAliasingProperty() { + return fontAntiAliasing; + } + + public String getFontAntiAliasing() { + return fontAntiAliasing.get(); + } + + public void setFontAntiAliasing(String value) { + this.fontAntiAliasing.set(value); + } + + public ObservableSet getUserJava() { + return userJava; + } + + public ObservableSet getDisabledJava() { + return disabledJava; + } + + public static final class Serializer implements JsonSerializer, JsonDeserializer { + private static final Set knownFields = new HashSet<>(Arrays.asList( + "agreementVersion", + "platformPromptVersion", + "logRetention", + "userJava", + "disabledJava", + "enableOfflineAccount", + "fontAntiAliasing" + )); + + @Override + public JsonElement serialize(GlobalConfig src, Type typeOfSrc, JsonSerializationContext context) { + if (src == null) { + return JsonNull.INSTANCE; + } + + JsonObject jsonObject = new JsonObject(); + jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion())); + jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion())); + jsonObject.add("logRetention", context.serialize(src.getLogRetention())); + jsonObject.add("fontAntiAliasing", context.serialize(src.getFontAntiAliasing())); + if (src.enableOfflineAccount.get()) + jsonObject.addProperty("enableOfflineAccount", true); + + if (!src.getUserJava().isEmpty()) + jsonObject.add("userJava", context.serialize(src.getUserJava())); + + if (!src.getDisabledJava().isEmpty()) + jsonObject.add("disabledJava", context.serialize(src.getDisabledJava())); + + for (Map.Entry entry : src.unknownFields.entrySet()) { + jsonObject.add(entry.getKey(), context.serialize(entry.getValue())); + } + + return jsonObject; + } + + @Override + public GlobalConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + if (!(json instanceof JsonObject)) return null; + + JsonObject obj = (JsonObject) json; + + GlobalConfig config = new GlobalConfig(); + config.setAgreementVersion(Optional.ofNullable(obj.get("agreementVersion")).map(JsonElement::getAsInt).orElse(0)); + config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0)); + config.setLogRetention(Optional.ofNullable(obj.get("logRetention")).map(JsonElement::getAsInt).orElse(20)); + config.setEnableOfflineAccount(Optional.ofNullable(obj.get("enableOfflineAccount")).map(JsonElement::getAsBoolean).orElse(false)); + config.setFontAntiAliasing(Optional.ofNullable(obj.get("fontAntiAliasing")).map(JsonElement::getAsString).orElse(null)); + + JsonElement userJava = obj.get("userJava"); + if (userJava != null && userJava.isJsonArray()) { + for (JsonElement element : userJava.getAsJsonArray()) { + config.userJava.add(element.getAsString()); + } + } + + JsonElement disabledJava = obj.get("disabledJava"); + if (disabledJava != null && disabledJava.isJsonArray()) { + for (JsonElement element : disabledJava.getAsJsonArray()) { + config.disabledJava.add(element.getAsString()); + } + } + + for (Map.Entry entry : obj.entrySet()) { + if (!knownFields.contains(entry.getKey())) { + config.unknownFields.put(entry.getKey(), context.deserialize(entry.getValue(), Object.class)); + } + } + + return config; + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/JavaVersionType.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/JavaVersionType.java new file mode 100644 index 0000000000..e56df50da6 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/JavaVersionType.java @@ -0,0 +1,25 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +/** + * @author Glavo + */ +public enum JavaVersionType { + DEFAULT, AUTO, VERSION, DETECTED, CUSTOM +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherVisibility.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherVisibility.java index b260f4b4b4..f3ca339376 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherVisibility.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherVisibility.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -43,5 +43,9 @@ public enum LauncherVisibility { /** * Hide the launcher and reopen it when game closes. */ - HIDE_AND_REOPEN + HIDE_AND_REOPEN; + + public boolean isDaemon() { + return this != CLOSE; + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profile.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profile.java index 961ceed7d3..d37d1787b1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profile.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profile.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,12 +19,12 @@ import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; -import com.jfoenix.concurrency.JFXUtilities; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.property.*; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.event.EventBus; import org.jackhuang.hmcl.event.EventPriority; import org.jackhuang.hmcl.event.RefreshedVersionsEvent; @@ -32,16 +32,15 @@ import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.Version; import org.jackhuang.hmcl.ui.WeakListenerHolder; -import org.jackhuang.hmcl.util.*; -import org.jackhuang.hmcl.util.javafx.ImmediateObjectProperty; -import org.jackhuang.hmcl.util.javafx.ImmediateStringProperty; +import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.javafx.ObservableHelper; -import java.io.File; import java.lang.reflect.Type; +import java.nio.file.Path; import java.util.Optional; import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; +import static org.jackhuang.hmcl.ui.FXUtils.runInFX; /** * @@ -66,17 +65,17 @@ public void setSelectedVersion(String selectedVersion) { this.selectedVersion.set(selectedVersion); } - private final ObjectProperty gameDir; + private final ObjectProperty gameDir; - public ObjectProperty gameDirProperty() { + public ObjectProperty gameDirProperty() { return gameDir; } - public File getGameDir() { + public Path getGameDir() { return gameDir.get(); } - public void setGameDir(File gameDir) { + public void setGameDir(Path gameDir) { this.gameDir.set(gameDir); } @@ -90,9 +89,9 @@ public VersionSetting getGlobal() { return global.get(); } - private final ImmediateStringProperty name; + private final SimpleStringProperty name; - public ImmediateStringProperty nameProperty() { + public StringProperty nameProperty() { return name; } @@ -104,7 +103,7 @@ public void setName(String name) { this.name.set(name); } - private BooleanProperty useRelativePath = new SimpleBooleanProperty(this, "useRelativePath", false); + private final BooleanProperty useRelativePath = new SimpleBooleanProperty(this, "useRelativePath", false); public BooleanProperty useRelativePathProperty() { return useRelativePath; @@ -119,20 +118,20 @@ public void setUseRelativePath(boolean useRelativePath) { } public Profile(String name) { - this(name, new File(".minecraft")); + this(name, Path.of(".minecraft")); } - public Profile(String name, File initialGameDir) { + public Profile(String name, Path initialGameDir) { this(name, initialGameDir, new VersionSetting()); } - public Profile(String name, File initialGameDir, VersionSetting global) { + public Profile(String name, Path initialGameDir, VersionSetting global) { this(name, initialGameDir, global, null, false); } - public Profile(String name, File initialGameDir, VersionSetting global, String selectedVersion, boolean useRelativePath) { - this.name = new ImmediateStringProperty(this, "name", name); - gameDir = new ImmediateObjectProperty<>(this, "gameDir", initialGameDir); + public Profile(String name, Path initialGameDir, VersionSetting global, String selectedVersion, boolean useRelativePath) { + this.name = new SimpleStringProperty(this, "name", name); + gameDir = new SimpleObjectProperty<>(this, "gameDir", initialGameDir); repository = new HMCLGameRepository(this, initialGameDir); this.global.set(global == null ? new VersionSetting() : global); this.selectedVersion.set(selectedVersion); @@ -146,7 +145,7 @@ public Profile(String name, File initialGameDir, VersionSetting global, String s } private void checkSelectedVersion() { - JFXUtilities.runInFX(() -> { + runInFX(() -> { if (!repository.isLoaded()) return; String newValue = selectedVersion.get(); if (!repository.hasVersion(newValue)) { @@ -164,17 +163,15 @@ public HMCLGameRepository getRepository() { } public DefaultDependencyManager getDependency() { - return new DefaultDependencyManager(repository, DownloadProviders.getDownloadProvider(), HMCLCacheRepository.REPOSITORY); + return getDependency(DownloadProviders.getDownloadProvider()); + } + + public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) { + return new DefaultDependencyManager(repository, downloadProvider, HMCLCacheRepository.REPOSITORY); } public VersionSetting getVersionSetting(String id) { - VersionSetting vs = repository.getVersionSetting(id); - if (vs == null || vs.isUsesGlobal()) { - getGlobal().setGlobal(true); // always keep global.isGlobal = true - getGlobal().setUsesGlobal(true); - return getGlobal(); - } else - return vs; + return repository.getVersionSetting(id); } @Override @@ -191,7 +188,7 @@ private void addPropertyChangedListener(InvalidationListener listener) { global.addListener(listener); gameDir.addListener(listener); useRelativePath.addListener(listener); - global.get().addPropertyChangedListener(listener); + global.get().addListener(listener); selectedVersion.addListener(listener); } @@ -211,6 +208,24 @@ private void invalidate() { Platform.runLater(observableHelper::invalidate); } + public static class ProfileVersion { + private final Profile profile; + private final String version; + + public ProfileVersion(Profile profile, String version) { + this.profile = profile; + this.version = version; + } + + public Profile getProfile() { + return profile; + } + + public String getVersion() { + return version; + } + } + public static final class Serializer implements JsonSerializer, JsonDeserializer { @Override public JsonElement serialize(Profile src, Type typeOfSrc, JsonSerializationContext context) { @@ -219,7 +234,7 @@ public JsonElement serialize(Profile src, Type typeOfSrc, JsonSerializationConte JsonObject jsonObject = new JsonObject(); jsonObject.add("global", context.serialize(src.getGlobal())); - jsonObject.addProperty("gameDir", src.getGameDir().getPath()); + jsonObject.addProperty("gameDir", src.getGameDir().toString()); jsonObject.addProperty("useRelativePath", src.isUseRelativePath()); jsonObject.addProperty("selectedMinecraftVersion", src.getSelectedVersion()); @@ -228,12 +243,11 @@ public JsonElement serialize(Profile src, Type typeOfSrc, JsonSerializationConte @Override public Profile deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { - if (json == null || json == JsonNull.INSTANCE || !(json instanceof JsonObject)) return null; - JsonObject obj = (JsonObject) json; + if (!(json instanceof JsonObject obj)) return null; String gameDir = Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse(""); return new Profile("Default", - new File(gameDir), + Path.of(gameDir), context.deserialize(obj.get("global"), VersionSetting.class), Optional.ofNullable(obj.get("selectedMinecraftVersion")).map(JsonElement::getAsString).orElse(""), Optional.ofNullable(obj.get("useRelativePath")).map(JsonElement::getAsBoolean).orElse(false)); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profiles.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profiles.java index b38fc8b9be..b4af9d5c66 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profiles.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profiles.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,25 +17,26 @@ */ package org.jackhuang.hmcl.setting; -import com.jfoenix.concurrency.JFXUtilities; import javafx.application.Platform; import javafx.beans.Observable; import javafx.beans.property.*; +import javafx.collections.FXCollections; import javafx.collections.ObservableList; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.event.EventBus; import org.jackhuang.hmcl.event.RefreshedVersionsEvent; -import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashSet; -import java.util.LinkedList; import java.util.List; +import java.util.TreeMap; import java.util.function.Consumer; -import java.util.stream.Collectors; import static javafx.collections.FXCollections.observableArrayList; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; +import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class Profiles { @@ -47,20 +48,17 @@ private Profiles() { } public static String getProfileDisplayName(Profile profile) { - switch (profile.getName()) { - case Profiles.DEFAULT_PROFILE: - return i18n("profile.default"); - case Profiles.HOME_PROFILE: - return i18n("profile.home"); - default: - return profile.getName(); - } + return switch (profile.getName()) { + case Profiles.DEFAULT_PROFILE -> i18n("profile.default"); + case Profiles.HOME_PROFILE -> i18n("profile.home"); + default -> profile.getName(); + }; } private static final ObservableList profiles = observableArrayList(profile -> new Observable[] { profile }); private static final ReadOnlyListWrapper profilesWrapper = new ReadOnlyListWrapper<>(profiles); - private static ObjectProperty selectedProfile = new SimpleObjectProperty() { + private static final ObjectProperty selectedProfile = new SimpleObjectProperty() { { profiles.addListener(onInvalidating(this::invalidated)); } @@ -103,8 +101,8 @@ protected void invalidated() { private static void checkProfiles() { if (profiles.isEmpty()) { - Profile current = new Profile(Profiles.DEFAULT_PROFILE, new File(".minecraft"), new VersionSetting(), null, true); - Profile home = new Profile(Profiles.HOME_PROFILE, Metadata.MINECRAFT_DIRECTORY.toFile()); + Profile current = new Profile(Profiles.DEFAULT_PROFILE, Path.of(".minecraft"), new VersionSetting(), null, true); + Profile home = new Profile(Profiles.HOME_PROFILE, Metadata.MINECRAFT_DIRECTORY); Platform.runLater(() -> profiles.addAll(current, home)); } } @@ -130,8 +128,11 @@ private static void updateProfileStorages() { if (!initialized) return; // update storage - config().getConfigurations().clear(); - config().getConfigurations().putAll(profiles.stream().collect(Collectors.toMap(Profile::getName, it -> it))); + TreeMap newConfigurations = new TreeMap<>(); + for (Profile profile : profiles) { + newConfigurations.put(profile.getName(), profile); + } + config().getConfigurations().setValue(FXCollections.observableMap(newConfigurations)); } /** @@ -162,7 +163,7 @@ static void init() { }); EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> { - JFXUtilities.runInFX(() -> { + runInFX(() -> { Profile profile = selectedProfile.get(); if (profile != null && profile.getRepository() == event.getSource()) { selectedVersion.bind(profile.selectedVersionProperty()); @@ -204,7 +205,7 @@ public static String getSelectedVersion() { return selectedVersion.get(); } - private static final List> versionsListeners = new LinkedList<>(); + private static final List> versionsListeners = new ArrayList<>(4); public static void registerVersionsListener(Consumer listener) { Profile profile = getSelectedProfile(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java index 830ea3bb8d..5df3828a90 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,100 +17,169 @@ */ package org.jackhuang.hmcl.setting; -import javafx.beans.binding.Bindings; -import javafx.beans.binding.ObjectBinding; -import javafx.beans.value.ObservableObjectValue; +import javafx.beans.InvalidationListener; import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.io.NetworkUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; -import java.net.Authenticator; -import java.net.InetSocketAddress; -import java.net.PasswordAuthentication; -import java.net.Proxy; -import java.net.Proxy.Type; +import java.io.IOException; +import java.net.*; +import java.util.List; +import java.util.Objects; import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.util.Logging.LOG; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class ProxyManager { - private ProxyManager() { + + private static final SimpleProxySelector NO_PROXY = new SimpleProxySelector(Proxy.NO_PROXY); + private static final ProxySelector SYSTEM_DEFAULT; + + static { + ProxySelector systemProxySelector = ProxySelector.getDefault(); + SYSTEM_DEFAULT = systemProxySelector != null + ? new ProxySelectorWrapper(systemProxySelector) + : NO_PROXY; } - private static ObjectBinding proxyProperty; + private static volatile @NotNull ProxySelector defaultProxySelector = SYSTEM_DEFAULT; + private static volatile @Nullable SimpleAuthenticator defaultAuthenticator = null; - public static Proxy getProxy() { - return proxyProperty.get(); + private static ProxySelector getProxySelector() { + if (config().hasProxy()) { + Proxy.Type proxyType = config().getProxyType(); + String host = config().getProxyHost(); + int port = config().getProxyPort(); + + if (proxyType == Proxy.Type.DIRECT || StringUtils.isBlank(host)) { + return NO_PROXY; + } else if (port < 0 || port > 0xFFFF) { + LOG.warning("Illegal proxy port: " + port); + return NO_PROXY; + } else { + return new ProxySelectorWrapper(new SimpleProxySelector(new Proxy(proxyType, new InetSocketAddress(host, port)))); + } + } else { + return ProxyManager.SYSTEM_DEFAULT; + } } - public static ObservableObjectValue proxyProperty() { - return proxyProperty; + private static SimpleAuthenticator getAuthenticator() { + if (config().hasProxy() && config().hasProxyAuth()) { + String username = config().getProxyUser(); + String password = config().getProxyPass(); + + if (username != null || password != null) + return new SimpleAuthenticator( + Objects.requireNonNullElse(username, ""), + Objects.requireNonNullElse(password, "").toCharArray() + ); + else + return null; + } else + return null; } static void init() { - proxyProperty = Bindings.createObjectBinding( - () -> { - String host = config().getProxyHost(); - int port = config().getProxyPort(); - if (!config().hasProxy() || StringUtils.isBlank(host) || config().getProxyType() == Proxy.Type.DIRECT) { - return Proxy.NO_PROXY; - } else { - if (port < 0 || port > 0xFFFF) { - LOG.warning("Illegal proxy port: " + port); - return Proxy.NO_PROXY; - } - return new Proxy(config().getProxyType(), new InetSocketAddress(host, port)); - } - }, - config().proxyTypeProperty(), - config().proxyHostProperty(), - config().proxyPortProperty(), - config().hasProxyProperty()); - - proxyProperty.addListener(any -> updateSystemProxy()); - updateSystemProxy(); + ProxySelector.setDefault(new ProxySelector() { + @Override + public List select(URI uri) { + return defaultProxySelector.select(uri); + } + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + defaultProxySelector.connectFailed(uri, sa, ioe); + } + }); Authenticator.setDefault(new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { - if (config().hasProxyAuth()) { - String username = config().getProxyUser(); - String password = config().getProxyPass(); - if (username != null && password != null) { - return new PasswordAuthentication(username, password.toCharArray()); - } - } - return null; + var defaultAuthenticator = ProxyManager.defaultAuthenticator; + return defaultAuthenticator != null ? defaultAuthenticator.getPasswordAuthentication() : null; } }); + + defaultProxySelector = getProxySelector(); + InvalidationListener updateProxySelector = observable -> defaultProxySelector = getProxySelector(); + config().proxyTypeProperty().addListener(updateProxySelector); + config().proxyHostProperty().addListener(updateProxySelector); + config().proxyPortProperty().addListener(updateProxySelector); + config().hasProxyProperty().addListener(updateProxySelector); + + defaultAuthenticator = getAuthenticator(); + InvalidationListener updateAuthenticator = observable -> defaultAuthenticator = getAuthenticator(); + config().hasProxyProperty().addListener(updateAuthenticator); + config().hasProxyAuthProperty().addListener(updateAuthenticator); + config().proxyUserProperty().addListener(updateAuthenticator); + config().proxyPassProperty().addListener(updateAuthenticator); } - private static void updateSystemProxy() { - Proxy proxy = proxyProperty.get(); - if (proxy.type() == Proxy.Type.DIRECT) { - System.clearProperty("http.proxyHost"); - System.clearProperty("http.proxyPort"); - System.clearProperty("https.proxyHost"); - System.clearProperty("https.proxyPort"); - System.clearProperty("socksProxyHost"); - System.clearProperty("socksProxyPort"); - } else { - InetSocketAddress address = (InetSocketAddress) proxy.address(); - String host = address.getHostString(); - String port = String.valueOf(address.getPort()); - if (proxy.type() == Type.HTTP) { - System.clearProperty("socksProxyHost"); - System.clearProperty("socksProxyPort"); - System.setProperty("http.proxyHost", host); - System.setProperty("http.proxyPort", port); - System.setProperty("https.proxyHost", host); - System.setProperty("https.proxyPort", port); - } else if (proxy.type() == Type.SOCKS) { - System.clearProperty("http.proxyHost"); - System.clearProperty("http.proxyPort"); - System.clearProperty("https.proxyHost"); - System.clearProperty("https.proxyPort"); - System.setProperty("socksProxyHost", host); - System.setProperty("socksProxyPort", port); + private static abstract class AbstractProxySelector extends ProxySelector { + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + if (uri == null || sa == null || ioe == null) { + throw new IllegalArgumentException("Arguments can't be null."); } } } + + private static final class SimpleProxySelector extends AbstractProxySelector { + private final List proxies; + + SimpleProxySelector(Proxy proxy) { + this.proxies = List.of(proxy); + } + + @Override + public List select(URI uri) { + if (uri == null) + throw new IllegalArgumentException("URI can't be null."); + return proxies; + } + + @Override + public String toString() { + return "SimpleProxySelector" + proxies; + } + } + + /// Wraps another ProxySelector to avoid using proxy for loopback addresses. + private static final class ProxySelectorWrapper extends AbstractProxySelector { + private final ProxySelector source; + + ProxySelectorWrapper(ProxySelector source) { + this.source = source; + } + + @Override + public List select(URI uri) { + if (uri == null) + throw new IllegalArgumentException("URI can't be null."); + + if (NetworkUtils.isLoopbackAddress(uri)) + return NO_PROXY.proxies; + + return source.select(uri); + } + } + + private static final class SimpleAuthenticator extends Authenticator { + private final String username; + private final char[] password; + + private SimpleAuthenticator(String username, char[] password) { + this.username = username; + this.password = password; + } + + @Override + public PasswordAuthentication getPasswordAuthentication() { + return getRequestorType() == RequestorType.PROXY ? new PasswordAuthentication(username, password) : null; + } + } + + private ProxyManager() { + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/SambaException.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/SambaException.java new file mode 100644 index 0000000000..d128bfae16 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/SambaException.java @@ -0,0 +1,18 @@ +package org.jackhuang.hmcl.setting; + +public final class SambaException extends RuntimeException { + public SambaException() { + } + + public SambaException(String message) { + super(message); + } + + public SambaException(String message, Throwable cause) { + super(message, cause); + } + + public SambaException(Throwable cause) { + super(cause); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Settings.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Settings.java index ba349c553a..6c61e9acfa 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Settings.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Settings.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,12 +20,13 @@ import javafx.beans.binding.Bindings; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.game.HMCLCacheRepository; +import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.util.CacheRepository; import org.jackhuang.hmcl.util.io.FileUtils; import static org.jackhuang.hmcl.setting.ConfigHolder.config; -public class Settings { +public final class Settings { private static Settings instance; @@ -48,6 +49,8 @@ private Settings() { ProxyManager.init(); Accounts.init(); Profiles.init(); + AuthlibInjectorServers.init(); + AnimationUtils.init(); CacheRepository.setInstance(HMCLCacheRepository.REPOSITORY); HMCLCacheRepository.REPOSITORY.directoryProperty().bind(Bindings.createStringBinding(() -> { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java new file mode 100644 index 0000000000..d61dcd0528 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java @@ -0,0 +1,141 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import javafx.beans.binding.Bindings; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.Scene; +import javafx.scene.paint.Color; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.Locale; + +import static org.jackhuang.hmcl.setting.ConfigHolder.config; + +/** + * @author Glavo + */ +public final class StyleSheets { + private static final int FONT_STYLE_SHEET_INDEX = 0; + private static final int THEME_STYLE_SHEET_INDEX = 1; + + private static final ObservableList stylesheets; + + static { + String[] array = new String[]{ + getFontStyleSheet(), + getThemeStyleSheet(), + "/assets/css/root.css" + }; + stylesheets = FXCollections.observableList(Arrays.asList(array)); + + FontManager.fontProperty().addListener(o -> stylesheets.set(FONT_STYLE_SHEET_INDEX, getFontStyleSheet())); + config().themeProperty().addListener(o -> stylesheets.set(THEME_STYLE_SHEET_INDEX, getThemeStyleSheet())); + } + + private static String toStyleSheetUri(String styleSheet) { + return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8)); + } + + private static String getFontStyleSheet() { + final String defaultCss = "/assets/css/font.css"; + final FontManager.FontReference font = FontManager.getFont(); + + if (font == null || "System".equals(font.getFamily())) + return defaultCss; + + String fontFamily = font.getFamily(); + String style = font.getStyle(); + String weight = null; + String posture = null; + + if (style != null) { + style = style.toLowerCase(Locale.ROOT); + + if (style.contains("thin")) + weight = "100"; + else if (style.contains("extralight") || style.contains("extra light") || style.contains("ultralight") | style.contains("ultra light")) + weight = "200"; + else if (style.contains("medium")) + weight = "500"; + else if (style.contains("semibold") || style.contains("semi bold") || style.contains("demibold") || style.contains("demi bold")) + weight = "600"; + else if (style.contains("extrabold") || style.contains("extra bold") || style.contains("ultrabold") || style.contains("ultra bold")) + weight = "800"; + else if (style.contains("black") || style.contains("heavy")) + weight = "900"; + else if (style.contains("light")) + weight = "lighter"; + else if (style.contains("bold")) + weight = "bold"; + + posture = style.contains("italic") || style.contains("oblique") ? "italic" : null; + } + + StringBuilder builder = new StringBuilder(); + builder.append(".root {"); + builder.append("-fx-font-family:\"").append(fontFamily).append("\";"); + + if (weight != null) + builder.append("-fx-font-weight:").append(weight).append(";"); + + if (posture != null) + builder.append("-fx-font-style:").append(posture).append(";"); + + builder.append('}'); + + return toStyleSheetUri(builder.toString()); + } + + private static String rgba(Color color, double opacity) { + return String.format("rgba(%d, %d, %d, %.1f)", + (int) Math.ceil(color.getRed() * 256), + (int) Math.ceil(color.getGreen() * 256), + (int) Math.ceil(color.getBlue() * 256), + opacity); + } + + private static String getThemeStyleSheet() { + final String blueCss = "/assets/css/blue.css"; + + Theme theme = config().getTheme(); + if (theme == null || theme.getPaint().equals(Theme.BLUE.getPaint())) + return blueCss; + + return toStyleSheetUri(".root {" + + "-fx-base-color:" + theme.getColor() + ';' + + "-fx-base-darker-color: derive(-fx-base-color, -10%);" + + "-fx-base-check-color: derive(-fx-base-color, 30%);" + + "-fx-rippler-color:" + rgba(theme.getPaint(), 0.3) + ';' + + "-fx-base-rippler-color: derive(" + rgba(theme.getPaint(), 0.3) + ", 100%);" + + "-fx-base-disabled-text-fill:" + rgba(theme.getForegroundColor(), 0.7) + ";" + + "-fx-base-text-fill:" + Theme.getColorDisplayName(theme.getForegroundColor()) + ";" + + "-theme-thumb:" + rgba(theme.getPaint(), 0.7) + ";" + + '}'); + } + + public static void init(Scene scene) { + Bindings.bindContent(scene.getStylesheets(), stylesheets); + } + + private StyleSheets() { + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Theme.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Theme.java index 85db751780..45f2bb2677 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Theme.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Theme.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,23 +24,19 @@ import javafx.beans.binding.ObjectBinding; import javafx.scene.paint.Color; -import org.jackhuang.hmcl.util.Logging; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.io.IOUtils; - -import java.io.File; import java.io.IOException; +import java.util.Locale; +import java.util.Objects; import java.util.Optional; -import java.util.logging.Level; import static org.jackhuang.hmcl.setting.ConfigHolder.config; @JsonAdapter(Theme.TypeAdapter.class) -public class Theme { +public final class Theme { public static final Theme BLUE = new Theme("blue", "#5C6BC0"); - + public static final Color BLACK = Color.web("#292929"); public static final Color[] SUGGESTED_COLORS = new Color[]{ - Color.web("#5C6BC0"), // blue + Color.web("#3D6DA3"), // blue Color.web("#283593"), // dark blue Color.web("#43A047"), // green Color.web("#E67E22"), // orange @@ -48,12 +44,19 @@ public class Theme { Color.web("#B71C1C") // red }; + public static Theme getTheme() { + Theme theme = config().getTheme(); + return theme == null ? BLUE : theme; + } + + private final Color paint; private final String color; private final String name; Theme(String name, String color) { this.name = name; - this.color = color; + this.color = Objects.requireNonNull(color); + this.paint = Color.web(color); } public String getName() { @@ -64,37 +67,22 @@ public String getColor() { return color; } + public Color getPaint() { + return paint; + } + public boolean isCustom() { return name.startsWith("#"); } public boolean isLight() { - return Color.web(color).grayscale().getRed() >= 0.5; + return paint.grayscale().getRed() >= 0.5; } public Color getForegroundColor() { return isLight() ? Color.BLACK : Color.WHITE; } - public String[] getStylesheets() { - String css; - try { - File temp = File.createTempFile("hmcl", ".css"); - FileUtils.writeText(temp, IOUtils.readFullyAsString(Theme.class.getResourceAsStream("/assets/css/custom.css")) - .replace("%base-color%", color) - .replace("%font-color%", getColorDisplayName(getForegroundColor()))); - css = temp.toURI().toString(); - } catch (IOException | NullPointerException e) { - Logging.LOG.log(Level.SEVERE, "Unable to create theme stylesheet. Fallback to blue theme.", e); - css = "/assets/css/blue.css"; - } - - return new String[]{ - css, - "/assets/css/root.css" - }; - } - public static Theme custom(String color) { if (!color.startsWith("#")) throw new IllegalArgumentException(); @@ -104,49 +92,67 @@ public static Theme custom(String color) { public static Optional getTheme(String name) { if (name == null) return Optional.empty(); - else if (name.equalsIgnoreCase("blue")) - return Optional.of(custom("#5C6BC0")); - else if (name.equalsIgnoreCase("darker_blue")) - return Optional.of(custom("#283593")); - else if (name.equalsIgnoreCase("green")) - return Optional.of(custom("#43A047")); - else if (name.equalsIgnoreCase("orange")) - return Optional.of(custom("#E67E22")); - else if (name.equalsIgnoreCase("purple")) - return Optional.of(custom("#9C27B0")); - else if (name.equalsIgnoreCase("red")) - return Optional.of(custom("#F44336")); - - if (name.startsWith("#")) + else if (name.startsWith("#")) try { Color.web(name); return Optional.of(custom(name)); } catch (IllegalArgumentException ignore) { } + else { + String color = null; + switch (name.toLowerCase(Locale.ROOT)) { + case "blue": + return Optional.of(BLUE); + case "darker_blue": + color = "#283593"; + break; + case "green": + color = "#43A047"; + break; + case "orange": + color = "#E67E22"; + break; + case "purple": + color = "#9C27B0"; + break; + case "red": + color = "#F44336"; + } + if (color != null) + return Optional.of(new Theme(name, color)); + } return Optional.empty(); } public static String getColorDisplayName(Color c) { - return c != null ? String.format("#%02x%02x%02x", Math.round(c.getRed() * 255.0D), Math.round(c.getGreen() * 255.0D), Math.round(c.getBlue() * 255.0D)).toUpperCase() : null; + return c != null ? String.format("#%02X%02X%02X", Math.round(c.getRed() * 255.0D), Math.round(c.getGreen() * 255.0D), Math.round(c.getBlue() * 255.0D)) : null; } + private static ObjectBinding FOREGROUND_FILL; + public static ObjectBinding foregroundFillBinding() { - return Bindings.createObjectBinding(() -> config().getTheme().getForegroundColor(), config().themeProperty()); + if (FOREGROUND_FILL == null) + FOREGROUND_FILL = Bindings.createObjectBinding( + () -> Theme.getTheme().getForegroundColor(), + config().themeProperty() + ); + + return FOREGROUND_FILL; } - public static ObjectBinding blackFillBinding() { - return Bindings.createObjectBinding(() -> Color.BLACK); + public static Color blackFill() { + return BLACK; } - public static ObjectBinding whiteFillBinding() { - return Bindings.createObjectBinding(() -> Color.WHITE); + public static Color whiteFill() { + return Color.WHITE; } public static class TypeAdapter extends com.google.gson.TypeAdapter { @Override public void write(JsonWriter out, Theme value) throws IOException { - out.value(value.getName().toLowerCase()); + out.value(value.getName().toLowerCase(Locale.ROOT)); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionIconType.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionIconType.java new file mode 100644 index 0000000000..51c1781305 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionIconType.java @@ -0,0 +1,51 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import javafx.scene.image.Image; +import org.jackhuang.hmcl.ui.FXUtils; + +public enum VersionIconType { + DEFAULT("/assets/img/grass.png"), + + GRASS("/assets/img/grass.png"), + CHEST("/assets/img/chest.png"), + CHICKEN("/assets/img/chicken.png"), + COMMAND("/assets/img/command.png"), + OPTIFINE("/assets/img/optifine.png"), + CRAFT_TABLE("/assets/img/craft_table.png"), + FABRIC("/assets/img/fabric.png"), + FORGE("/assets/img/forge.png"), + NEO_FORGE("/assets/img/neoforge.png"), + FURNACE("/assets/img/furnace.png"), + QUILT("/assets/img/quilt.png"), + APRIL_FOOLS("/assets/img/april_fools.png"), + CLEANROOM("/assets/img/cleanroom.png"); + + // Please append new items at last + + private final String resourceUrl; + + VersionIconType(String resourceUrl) { + this.resourceUrl = resourceUrl; + } + + public Image getIcon() { + return FXUtils.newBuiltinImage(resourceUrl); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionSetting.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionSetting.java index 8d2a44c50a..dbf6964d74 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionSetting.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionSetting.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,50 +19,53 @@ import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; - import javafx.beans.InvalidationListener; -import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.game.LaunchOptions; -import org.jackhuang.hmcl.util.*; -import org.jackhuang.hmcl.util.javafx.ImmediateBooleanProperty; -import org.jackhuang.hmcl.util.javafx.ImmediateIntegerProperty; -import org.jackhuang.hmcl.util.javafx.ImmediateObjectProperty; -import org.jackhuang.hmcl.util.javafx.ImmediateStringProperty; -import org.jackhuang.hmcl.util.platform.JavaVersion; -import org.jackhuang.hmcl.util.platform.OperatingSystem; - -import java.io.File; +import javafx.beans.Observable; +import javafx.beans.property.*; +import org.jackhuang.hmcl.game.*; +import org.jackhuang.hmcl.java.JavaManager; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.javafx.ObservableHelper; +import org.jackhuang.hmcl.util.javafx.PropertyUtils; +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.util.platform.SystemInfo; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; + import java.io.IOException; import java.lang.reflect.Type; +import java.nio.file.InvalidPathException; import java.nio.file.Paths; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** - * * @author huangyuhui */ @JsonAdapter(VersionSetting.Serializer.class) -public final class VersionSetting { +public final class VersionSetting implements Cloneable, Observable { - public transient String id; + private static final int SUGGESTED_MEMORY; - private boolean global = false; - - public boolean isGlobal() { - return global; + static { + double totalMemoryMB = MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize()); + SUGGESTED_MEMORY = totalMemoryMB >= 32768 + ? 8192 + : Integer.max((int) (Math.round(totalMemoryMB / 4.0 / 128.0) * 128), 256); } - public void setGlobal(boolean global) { - this.global = global; + private final transient ObservableHelper helper = new ObservableHelper(this); + + public VersionSetting() { + PropertyUtils.attachListener(this, helper); } - private final ImmediateBooleanProperty usesGlobalProperty = new ImmediateBooleanProperty(this, "usesGlobal", false); + private final BooleanProperty usesGlobalProperty = new SimpleBooleanProperty(this, "usesGlobal", true); - public ImmediateBooleanProperty usesGlobalProperty() { + public BooleanProperty usesGlobalProperty() { return usesGlobalProperty; } @@ -71,7 +74,7 @@ public ImmediateBooleanProperty usesGlobalProperty() { * 1. Global settings. * 2. Version settings. * If a version claims that it uses global settings, its version setting will be disabled. - * + *

* Defaults false because if one version uses global first, custom version file will not be generated. */ public boolean isUsesGlobal() { @@ -84,33 +87,47 @@ public void setUsesGlobal(boolean usesGlobal) { // java - private final ImmediateStringProperty javaProperty = new ImmediateStringProperty(this, "java", ""); + private final ObjectProperty javaVersionTypeProperty = new SimpleObjectProperty<>(this, "javaVersionType", JavaVersionType.AUTO); - public ImmediateStringProperty javaProperty() { - return javaProperty; + public ObjectProperty javaVersionTypeProperty() { + return javaVersionTypeProperty; } - /** - * Java version or "Custom" if user customizes java directory, "Default" if the jvm that this app relies on. - */ - public String getJava() { - return javaProperty.get(); + public JavaVersionType getJavaVersionType() { + return javaVersionTypeProperty.get(); } - public void setJava(String java) { - javaProperty.set(java); + public void setJavaVersionType(JavaVersionType javaVersionType) { + javaVersionTypeProperty.set(javaVersionType); } - public boolean isUsesCustomJavaDir() { - return "Custom".equals(getJava()); + private final StringProperty javaVersionProperty = new SimpleStringProperty(this, "javaVersion", ""); + + public StringProperty javaVersionProperty() { + return javaVersionProperty; + } + + public String getJavaVersion() { + return javaVersionProperty.get(); + } + + public void setJavaVersion(String java) { + javaVersionProperty.set(java); } public void setUsesCustomJavaDir() { - setJava("Custom"); + setJavaVersionType(JavaVersionType.CUSTOM); + setJavaVersion(""); + setDefaultJavaPath(null); + } + + public void setJavaAutoSelected() { + setJavaVersionType(JavaVersionType.AUTO); + setJavaVersion(""); setDefaultJavaPath(null); } - private final ImmediateStringProperty defaultJavaPathProperty = new ImmediateStringProperty(this, "defaultJavaPath", ""); + private final StringProperty defaultJavaPathProperty = new SimpleStringProperty(this, "defaultJavaPath", ""); /** * Path to Java executable, or null if user customizes java directory. @@ -120,13 +137,50 @@ public String getDefaultJavaPath() { return defaultJavaPathProperty.get(); } + public StringProperty defaultJavaPathPropertyProperty() { + return defaultJavaPathProperty; + } + public void setDefaultJavaPath(String defaultJavaPath) { defaultJavaPathProperty.set(defaultJavaPath); } - private final ImmediateStringProperty javaDirProperty = new ImmediateStringProperty(this, "javaDir", ""); + /** + * 0 - .minecraft/versions/<version>/natives/
+ */ + private final ObjectProperty nativesDirTypeProperty = new SimpleObjectProperty<>(this, "nativesDirType", NativesDirectoryType.VERSION_FOLDER); + + public ObjectProperty nativesDirTypeProperty() { + return nativesDirTypeProperty; + } + + public NativesDirectoryType getNativesDirType() { + return nativesDirTypeProperty.get(); + } + + public void setNativesDirType(NativesDirectoryType nativesDirType) { + nativesDirTypeProperty.set(nativesDirType); + } + + // Path to lwjgl natives directory + + private final StringProperty nativesDirProperty = new SimpleStringProperty(this, "nativesDirProperty", ""); + + public StringProperty nativesDirProperty() { + return nativesDirProperty; + } + + public String getNativesDir() { + return nativesDirProperty.get(); + } + + public void setNativesDir(String nativesDir) { + nativesDirProperty.set(nativesDir); + } - public ImmediateStringProperty javaDirProperty() { + private final StringProperty javaDirProperty = new SimpleStringProperty(this, "javaDir", ""); + + public StringProperty javaDirProperty() { return javaDirProperty; } @@ -141,9 +195,9 @@ public void setJavaDir(String javaDir) { javaDirProperty.set(javaDir); } - private final ImmediateStringProperty wrapperProperty = new ImmediateStringProperty(this, "wrapper", ""); + private final StringProperty wrapperProperty = new SimpleStringProperty(this, "wrapper", ""); - public ImmediateStringProperty wrapperProperty() { + public StringProperty wrapperProperty() { return wrapperProperty; } @@ -158,9 +212,9 @@ public void setWrapper(String wrapper) { wrapperProperty.set(wrapper); } - private final ImmediateStringProperty permSizeProperty = new ImmediateStringProperty(this, "permSize", ""); + private final StringProperty permSizeProperty = new SimpleStringProperty(this, "permSize", ""); - public ImmediateStringProperty permSizeProperty() { + public StringProperty permSizeProperty() { return permSizeProperty; } @@ -175,9 +229,9 @@ public void setPermSize(String permSize) { permSizeProperty.set(permSize); } - private final ImmediateIntegerProperty maxMemoryProperty = new ImmediateIntegerProperty(this, "maxMemory", OperatingSystem.SUGGESTED_MEMORY); + private final IntegerProperty maxMemoryProperty = new SimpleIntegerProperty(this, "maxMemory", SUGGESTED_MEMORY); - public ImmediateIntegerProperty maxMemoryProperty() { + public IntegerProperty maxMemoryProperty() { return maxMemoryProperty; } @@ -195,9 +249,9 @@ public void setMaxMemory(int maxMemory) { /** * The minimum memory that JVM can allocate for heap. */ - private final ImmediateObjectProperty minMemoryProperty = new ImmediateObjectProperty<>(this, "minMemory", null); + private final ObjectProperty minMemoryProperty = new SimpleObjectProperty<>(this, "minMemory", null); - public ImmediateObjectProperty minMemoryProperty() { + public ObjectProperty minMemoryProperty() { return minMemoryProperty; } @@ -209,9 +263,23 @@ public void setMinMemory(Integer minMemory) { minMemoryProperty.set(minMemory); } - private final ImmediateStringProperty preLaunchCommandProperty = new ImmediateStringProperty(this, "precalledCommand", ""); + private final BooleanProperty autoMemory = new SimpleBooleanProperty(this, "autoMemory", true); + + public boolean isAutoMemory() { + return autoMemory.get(); + } + + public BooleanProperty autoMemoryProperty() { + return autoMemory; + } + + public void setAutoMemory(boolean autoMemory) { + this.autoMemory.set(autoMemory); + } + + private final StringProperty preLaunchCommandProperty = new SimpleStringProperty(this, "precalledCommand", ""); - public ImmediateStringProperty preLaunchCommandProperty() { + public StringProperty preLaunchCommandProperty() { return preLaunchCommandProperty; } @@ -227,11 +295,29 @@ public void setPreLaunchCommand(String preLaunchCommand) { preLaunchCommandProperty.set(preLaunchCommand); } + private final StringProperty postExitCommand = new SimpleStringProperty(this, "postExitCommand", ""); + + public StringProperty postExitCommandProperty() { + return postExitCommand; + } + + /** + * The command that will be executed after game exits. + * Operating system relevant. + */ + public String getPostExitCommand() { + return postExitCommand.get(); + } + + public void setPostExitCommand(String postExitCommand) { + this.postExitCommand.set(postExitCommand); + } + // options - private final ImmediateStringProperty javaArgsProperty = new ImmediateStringProperty(this, "javaArgs", ""); + private final StringProperty javaArgsProperty = new SimpleStringProperty(this, "javaArgs", ""); - public ImmediateStringProperty javaArgsProperty() { + public StringProperty javaArgsProperty() { return javaArgsProperty; } @@ -246,9 +332,9 @@ public void setJavaArgs(String javaArgs) { javaArgsProperty.set(javaArgs); } - private final ImmediateStringProperty minecraftArgsProperty = new ImmediateStringProperty(this, "minecraftArgs", ""); + private final StringProperty minecraftArgsProperty = new SimpleStringProperty(this, "minecraftArgs", ""); - public ImmediateStringProperty minecraftArgsProperty() { + public StringProperty minecraftArgsProperty() { return minecraftArgsProperty; } @@ -263,9 +349,23 @@ public void setMinecraftArgs(String minecraftArgs) { minecraftArgsProperty.set(minecraftArgs); } - private final ImmediateBooleanProperty noJVMArgsProperty = new ImmediateBooleanProperty(this, "noJVMArgs", false); + private final StringProperty environmentVariablesProperty = new SimpleStringProperty(this, "environmentVariables", ""); + + public StringProperty environmentVariablesProperty() { + return environmentVariablesProperty; + } + + public String getEnvironmentVariables() { + return environmentVariablesProperty.get(); + } + + public void setEnvironmentVariables(String env) { + environmentVariablesProperty.set(env); + } + + private final BooleanProperty noJVMArgsProperty = new SimpleBooleanProperty(this, "noJVMArgs", false); - public ImmediateBooleanProperty noJVMArgsProperty() { + public BooleanProperty noJVMArgsProperty() { return noJVMArgsProperty; } @@ -280,9 +380,23 @@ public void setNoJVMArgs(boolean noJVMArgs) { noJVMArgsProperty.set(noJVMArgs); } - private final ImmediateBooleanProperty notCheckJVMProperty = new ImmediateBooleanProperty(this, "notCheckJVM", false); + private final BooleanProperty noOptimizingJVMArgsProperty = new SimpleBooleanProperty(this, "noOptimizingJVMArgs", false); + + public BooleanProperty noOptimizingJVMArgsProperty() { + return noOptimizingJVMArgsProperty; + } + + public boolean isNoOptimizingJVMArgs() { + return noOptimizingJVMArgsProperty.get(); + } + + public void setNoOptimizingJVMArgs(boolean noOptimizingJVMArgs) { + noOptimizingJVMArgsProperty.set(noOptimizingJVMArgs); + } + + private final BooleanProperty notCheckJVMProperty = new SimpleBooleanProperty(this, "notCheckJVM", false); - public ImmediateBooleanProperty notCheckJVMProperty() { + public BooleanProperty notCheckJVMProperty() { return notCheckJVMProperty; } @@ -297,9 +411,9 @@ public void setNotCheckJVM(boolean notCheckJVM) { notCheckJVMProperty.set(notCheckJVM); } - private final ImmediateBooleanProperty notCheckGameProperty = new ImmediateBooleanProperty(this, "notCheckGame", false); + private final BooleanProperty notCheckGameProperty = new SimpleBooleanProperty(this, "notCheckGame", false); - public ImmediateBooleanProperty notCheckGameProperty() { + public BooleanProperty notCheckGameProperty() { return notCheckGameProperty; } @@ -314,9 +428,23 @@ public void setNotCheckGame(boolean notCheckGame) { notCheckGameProperty.set(notCheckGame); } - private final ImmediateBooleanProperty showLogsProperty = new ImmediateBooleanProperty(this, "showLogs", false); + private final BooleanProperty notPatchNativesProperty = new SimpleBooleanProperty(this, "notPatchNatives", false); - public ImmediateBooleanProperty showLogsProperty() { + public BooleanProperty notPatchNativesProperty() { + return notPatchNativesProperty; + } + + public boolean isNotPatchNatives() { + return notPatchNativesProperty.get(); + } + + public void setNotPatchNatives(boolean notPatchNatives) { + notPatchNativesProperty.set(notPatchNatives); + } + + private final BooleanProperty showLogsProperty = new SimpleBooleanProperty(this, "showLogs", false); + + public BooleanProperty showLogsProperty() { return showLogsProperty; } @@ -333,15 +461,15 @@ public void setShowLogs(boolean showLogs) { // Minecraft settings. - private final ImmediateStringProperty serverIpProperty = new ImmediateStringProperty(this, "serverIp", ""); + private final StringProperty serverIpProperty = new SimpleStringProperty(this, "serverIp", ""); - public ImmediateStringProperty serverIpProperty() { + public StringProperty serverIpProperty() { return serverIpProperty; } /** - * The server ip that will be entered after Minecraft successfully loaded immediately. - * + * The server ip that will be entered after Minecraft successfully loaded ly. + *

* Format: ip:port or without port. */ public String getServerIp() { @@ -353,9 +481,9 @@ public void setServerIp(String serverIp) { } - private final ImmediateBooleanProperty fullscreenProperty = new ImmediateBooleanProperty(this, "fullscreen", false); + private final BooleanProperty fullscreenProperty = new SimpleBooleanProperty(this, "fullscreen", false); - public ImmediateBooleanProperty fullscreenProperty() { + public BooleanProperty fullscreenProperty() { return fullscreenProperty; } @@ -370,15 +498,15 @@ public void setFullscreen(boolean fullscreen) { fullscreenProperty.set(fullscreen); } - private final ImmediateIntegerProperty widthProperty = new ImmediateIntegerProperty(this, "width", 854); + private final IntegerProperty widthProperty = new SimpleIntegerProperty(this, "width", 854); - public ImmediateIntegerProperty widthProperty() { + public IntegerProperty widthProperty() { return widthProperty; } /** * The width of Minecraft window, defaults 800. - * + *

* The field saves int value. * String type prevents unexpected value from JsonParseException. * We can only reset this field instead of recreating the whole setting file. @@ -391,16 +519,15 @@ public void setWidth(int width) { widthProperty.set(width); } + private final IntegerProperty heightProperty = new SimpleIntegerProperty(this, "height", 480); - private final ImmediateIntegerProperty heightProperty = new ImmediateIntegerProperty(this, "height", 480); - - public ImmediateIntegerProperty heightProperty() { + public IntegerProperty heightProperty() { return heightProperty; } /** * The height of Minecraft window, defaults 480. - * + *

* The field saves int value. * String type prevents unexpected value from JsonParseException. * We can only reset this field instead of recreating the whole setting file. @@ -417,26 +544,26 @@ public void setHeight(int height) { * 0 - .minecraft
* 1 - .minecraft/versions/<version>/
*/ - private final ImmediateObjectProperty gameDirTypeProperty = new ImmediateObjectProperty<>(this, "gameDirType", EnumGameDirectory.ROOT_FOLDER); + private final ObjectProperty gameDirTypeProperty = new SimpleObjectProperty<>(this, "gameDirType", GameDirectoryType.ROOT_FOLDER); - public ImmediateObjectProperty gameDirTypeProperty() { + public ObjectProperty gameDirTypeProperty() { return gameDirTypeProperty; } - public EnumGameDirectory getGameDirType() { + public GameDirectoryType getGameDirType() { return gameDirTypeProperty.get(); } - public void setGameDirType(EnumGameDirectory gameDirType) { + public void setGameDirType(GameDirectoryType gameDirType) { gameDirTypeProperty.set(gameDirType); } /** * Your custom gameDir */ - private final ImmediateStringProperty gameDirProperty = new ImmediateStringProperty(this, "gameDir", ""); + private final StringProperty gameDirProperty = new SimpleStringProperty(this, "gameDir", ""); - public ImmediateStringProperty gameDirProperty() { + public StringProperty gameDirProperty() { return gameDirProperty; } @@ -448,6 +575,76 @@ public void setGameDir(String gameDir) { gameDirProperty.set(gameDir); } + private final ObjectProperty processPriorityProperty = new SimpleObjectProperty<>(this, "processPriority", ProcessPriority.NORMAL); + + public ObjectProperty processPriorityProperty() { + return processPriorityProperty; + } + + public ProcessPriority getProcessPriority() { + return processPriorityProperty.get(); + } + + public void setProcessPriority(ProcessPriority processPriority) { + processPriorityProperty.set(processPriority); + } + + private final ObjectProperty rendererProperty = new SimpleObjectProperty<>(this, "renderer", Renderer.DEFAULT); + + public Renderer getRenderer() { + return rendererProperty.get(); + } + + public ObjectProperty rendererProperty() { + return rendererProperty; + } + + public void setRenderer(Renderer renderer) { + this.rendererProperty.set(renderer); + } + + private final BooleanProperty useNativeGLFW = new SimpleBooleanProperty(this, "nativeGLFW", false); + + public boolean isUseNativeGLFW() { + return useNativeGLFW.get(); + } + + public BooleanProperty useNativeGLFWProperty() { + return useNativeGLFW; + } + + public void setUseNativeGLFW(boolean useNativeGLFW) { + this.useNativeGLFW.set(useNativeGLFW); + } + + private final BooleanProperty useNativeOpenAL = new SimpleBooleanProperty(this, "nativeOpenAL", false); + + public boolean isUseNativeOpenAL() { + return useNativeOpenAL.get(); + } + + public BooleanProperty useNativeOpenALProperty() { + return useNativeOpenAL; + } + + public void setUseNativeOpenAL(boolean useNativeOpenAL) { + this.useNativeOpenAL.set(useNativeOpenAL); + } + + private final ObjectProperty versionIcon = new SimpleObjectProperty<>(this, "versionIcon", VersionIconType.DEFAULT); + + public VersionIconType getVersionIcon() { + return versionIcon.get(); + } + + public ObjectProperty versionIconProperty() { + return versionIcon; + } + + public void setVersionIcon(VersionIconType versionIcon) { + this.versionIcon.set(versionIcon); + } + // launcher settings /** @@ -455,9 +652,9 @@ public void setGameDir(String gameDir) { * 1 - Hide the launcher when the game starts.
* 2 - Keep the launcher open.
*/ - private final ImmediateObjectProperty launcherVisibilityProperty = new ImmediateObjectProperty<>(this, "launcherVisibility", LauncherVisibility.HIDE); + private final ObjectProperty launcherVisibilityProperty = new SimpleObjectProperty<>(this, "launcherVisibility", LauncherVisibility.HIDE); - public ImmediateObjectProperty launcherVisibilityProperty() { + public ObjectProperty launcherVisibilityProperty() { return launcherVisibilityProperty; } @@ -469,91 +666,86 @@ public void setLauncherVisibility(LauncherVisibility launcherVisibility) { launcherVisibilityProperty.set(launcherVisibility); } - public JavaVersion getJavaVersion() throws InterruptedException { - // TODO: lazy initialization may result in UI suspension. - if (StringUtils.isBlank(getJava())) - setJava(StringUtils.isBlank(getJavaDir()) ? "Default" : "Custom"); - if ("Default".equals(getJava())) return JavaVersion.fromCurrentEnvironment(); - else if (isUsesCustomJavaDir()) { - try { - return JavaVersion.fromExecutable(Paths.get(getJavaDir())); - } catch (IOException e) { - return null; // Custom Java Directory not found, - } - } else if (StringUtils.isNotBlank(getJava())) { - List matchedJava = JavaVersion.getJavas().stream() - .filter(java -> java.getVersion().equals(getJava())) - .collect(Collectors.toList()); - if (matchedJava.isEmpty()) { - setJava("Default"); - return JavaVersion.fromCurrentEnvironment(); - } else { - return matchedJava.stream() - .filter(java -> java.getBinary().toString().equals(getDefaultJavaPath())) - .findFirst() - .orElse(matchedJava.get(0)); + public JavaRuntime getJava(GameVersionNumber gameVersion, Version version) throws InterruptedException { + switch (getJavaVersionType()) { + case DEFAULT: + return JavaRuntime.getDefault(); + case AUTO: + return JavaManager.findSuitableJava(gameVersion, version); + case CUSTOM: + try { + return JavaManager.getJava(Paths.get(getJavaDir())); + } catch (IOException | InvalidPathException e) { + return null; // Custom Java not found + } + case VERSION: { + String javaVersion = getJavaVersion(); + if (StringUtils.isBlank(javaVersion)) { + return JavaManager.findSuitableJava(gameVersion, version); + } + + int majorVersion = -1; + try { + majorVersion = Integer.parseInt(javaVersion); + } catch (NumberFormatException ignored) { + } + + if (majorVersion < 0) { + LOG.warning("Invalid Java version: " + javaVersion); + return null; + } + + final int finalMajorVersion = majorVersion; + Collection allJava = JavaManager.getAllJava().stream() + .filter(it -> it.getParsedVersion() == finalMajorVersion) + .collect(Collectors.toList()); + return JavaManager.findSuitableJava(allJava, gameVersion, version); } - } else throw new Error(); - } - - public void setJavaVersion(JavaVersion java) { - setJava(java.getVersion()); - setDefaultJavaPath(java.getBinary().toString()); - } - - public void addPropertyChangedListener(InvalidationListener listener) { - usesGlobalProperty.addListener(listener); - javaProperty.addListener(listener); - javaDirProperty.addListener(listener); - wrapperProperty.addListener(listener); - permSizeProperty.addListener(listener); - maxMemoryProperty.addListener(listener); - minMemoryProperty.addListener(listener); - preLaunchCommandProperty.addListener(listener); - javaArgsProperty.addListener(listener); - minecraftArgsProperty.addListener(listener); - noJVMArgsProperty.addListener(listener); - notCheckGameProperty.addListener(listener); - notCheckJVMProperty.addListener(listener); - showLogsProperty.addListener(listener); - serverIpProperty.addListener(listener); - fullscreenProperty.addListener(listener); - widthProperty.addListener(listener); - heightProperty.addListener(listener); - gameDirTypeProperty.addListener(listener); - gameDirProperty.addListener(listener); - launcherVisibilityProperty.addListener(listener); - defaultJavaPathProperty.addListener(listener); - } - - public LaunchOptions toLaunchOptions(File gameDir) throws InterruptedException { - JavaVersion javaVersion = Optional.ofNullable(getJavaVersion()).orElse(JavaVersion.fromCurrentEnvironment()); - LaunchOptions.Builder builder = new LaunchOptions.Builder() - .setGameDir(gameDir) - .setJava(javaVersion) - .setVersionName(Metadata.TITLE) - .setProfileName(Metadata.TITLE) - .setMinecraftArgs(getMinecraftArgs()) - .setJavaArgs(getJavaArgs()) - .setMaxMemory(getMaxMemory()) - .setMinMemory(getMinMemory()) - .setMetaspace(Lang.toIntOrNull(getPermSize())) - .setWidth(getWidth()) - .setHeight(getHeight()) - .setFullscreen(isFullscreen()) - .setServerIp(getServerIp()) - .setWrapper(getWrapper()) - .setPrecalledCommand(getPreLaunchCommand()) - .setNoGeneratedJVMArgs(isNoJVMArgs()); - if (config().hasProxy()) { - builder.setProxyHost(config().getProxyHost()); - builder.setProxyPort(config().getProxyPort()); - if (config().hasProxyAuth()) { - builder.setProxyUser(config().getProxyUser()); - builder.setProxyPass(config().getProxyPass()); + case DETECTED: { + String javaVersion = getJavaVersion(); + if (StringUtils.isBlank(javaVersion)) { + return JavaManager.findSuitableJava(gameVersion, version); + } + + try { + String defaultJavaPath = getDefaultJavaPath(); + if (StringUtils.isNotBlank(defaultJavaPath)) { + JavaRuntime java = JavaManager.getJava(Paths.get(defaultJavaPath).toRealPath()); + if (java != null && java.getVersion().equals(javaVersion)) { + return java; + } + } + } catch (IOException | InvalidPathException ignored) { + } + + for (JavaRuntime java : JavaManager.getAllJava()) { + if (java.getVersion().equals(javaVersion)) { + return java; + } + } + + return null; } + default: + throw new AssertionError("JavaVersionType: " + getJavaVersionType()); } - return builder.create(); + } + + @Override + public void addListener(InvalidationListener listener) { + helper.addListener(listener); + } + + @Override + public void removeListener(InvalidationListener listener) { + helper.removeListener(listener); + } + + @Override + public VersionSetting clone() { + VersionSetting cloned = new VersionSetting(); + PropertyUtils.copyProperties(this, cloned); + return cloned; } public static class Serializer implements JsonSerializer, JsonDeserializer { @@ -565,62 +757,135 @@ public JsonElement serialize(VersionSetting src, Type typeOfSrc, JsonSerializati obj.addProperty("usesGlobal", src.isUsesGlobal()); obj.addProperty("javaArgs", src.getJavaArgs()); obj.addProperty("minecraftArgs", src.getMinecraftArgs()); - obj.addProperty("maxMemory", src.getMaxMemory() <= 0 ? OperatingSystem.SUGGESTED_MEMORY : src.getMaxMemory()); + obj.addProperty("environmentVariables", src.getEnvironmentVariables()); + obj.addProperty("maxMemory", src.getMaxMemory() <= 0 ? SUGGESTED_MEMORY : src.getMaxMemory()); obj.addProperty("minMemory", src.getMinMemory()); + obj.addProperty("autoMemory", src.isAutoMemory()); obj.addProperty("permSize", src.getPermSize()); obj.addProperty("width", src.getWidth()); obj.addProperty("height", src.getHeight()); obj.addProperty("javaDir", src.getJavaDir()); obj.addProperty("precalledCommand", src.getPreLaunchCommand()); + obj.addProperty("postExitCommand", src.getPostExitCommand()); obj.addProperty("serverIp", src.getServerIp()); - obj.addProperty("java", src.getJava()); obj.addProperty("wrapper", src.getWrapper()); obj.addProperty("fullscreen", src.isFullscreen()); obj.addProperty("noJVMArgs", src.isNoJVMArgs()); obj.addProperty("notCheckGame", src.isNotCheckGame()); obj.addProperty("notCheckJVM", src.isNotCheckJVM()); + obj.addProperty("notPatchNatives", src.isNotPatchNatives()); obj.addProperty("showLogs", src.isShowLogs()); obj.addProperty("gameDir", src.getGameDir()); obj.addProperty("launcherVisibility", src.getLauncherVisibility().ordinal()); + obj.addProperty("processPriority", src.getProcessPriority().ordinal()); + obj.addProperty("useNativeGLFW", src.isUseNativeGLFW()); + obj.addProperty("useNativeOpenAL", src.isUseNativeOpenAL()); obj.addProperty("gameDirType", src.getGameDirType().ordinal()); obj.addProperty("defaultJavaPath", src.getDefaultJavaPath()); + obj.addProperty("nativesDir", src.getNativesDir()); + obj.addProperty("nativesDirType", src.getNativesDirType().ordinal()); + obj.addProperty("versionIcon", src.getVersionIcon().ordinal()); + + obj.addProperty("javaVersionType", src.getJavaVersionType().name()); + String java; + switch (src.getJavaVersionType()) { + case DEFAULT: + java = "Default"; + break; + case AUTO: + java = "Auto"; + break; + case CUSTOM: + java = "Custom"; + break; + default: + java = src.getJavaVersion(); + break; + } + obj.addProperty("java", java); + + obj.addProperty("renderer", src.getRenderer().name()); + if (src.getRenderer() == Renderer.LLVMPIPE) + obj.addProperty("useSoftwareRenderer", true); return obj; } @Override public VersionSetting deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { - if (json == null || json == JsonNull.INSTANCE || !(json instanceof JsonObject)) + if (!(json instanceof JsonObject)) return null; JsonObject obj = (JsonObject) json; - int maxMemoryN = parseJsonPrimitive(Optional.ofNullable(obj.get("maxMemory")).map(JsonElement::getAsJsonPrimitive).orElse(null), OperatingSystem.SUGGESTED_MEMORY); - if (maxMemoryN <= 0) maxMemoryN = OperatingSystem.SUGGESTED_MEMORY; + int maxMemoryN = parseJsonPrimitive(Optional.ofNullable(obj.get("maxMemory")).map(JsonElement::getAsJsonPrimitive).orElse(null), SUGGESTED_MEMORY); + if (maxMemoryN <= 0) maxMemoryN = SUGGESTED_MEMORY; VersionSetting vs = new VersionSetting(); vs.setUsesGlobal(Optional.ofNullable(obj.get("usesGlobal")).map(JsonElement::getAsBoolean).orElse(false)); vs.setJavaArgs(Optional.ofNullable(obj.get("javaArgs")).map(JsonElement::getAsString).orElse("")); vs.setMinecraftArgs(Optional.ofNullable(obj.get("minecraftArgs")).map(JsonElement::getAsString).orElse("")); + vs.setEnvironmentVariables(Optional.ofNullable(obj.get("environmentVariables")).map(JsonElement::getAsString).orElse("")); vs.setMaxMemory(maxMemoryN); vs.setMinMemory(Optional.ofNullable(obj.get("minMemory")).map(JsonElement::getAsInt).orElse(null)); + vs.setAutoMemory(Optional.ofNullable(obj.get("autoMemory")).map(JsonElement::getAsBoolean).orElse(true)); vs.setPermSize(Optional.ofNullable(obj.get("permSize")).map(JsonElement::getAsString).orElse("")); vs.setWidth(Optional.ofNullable(obj.get("width")).map(JsonElement::getAsJsonPrimitive).map(this::parseJsonPrimitive).orElse(0)); vs.setHeight(Optional.ofNullable(obj.get("height")).map(JsonElement::getAsJsonPrimitive).map(this::parseJsonPrimitive).orElse(0)); vs.setJavaDir(Optional.ofNullable(obj.get("javaDir")).map(JsonElement::getAsString).orElse("")); vs.setPreLaunchCommand(Optional.ofNullable(obj.get("precalledCommand")).map(JsonElement::getAsString).orElse("")); + vs.setPostExitCommand(Optional.ofNullable(obj.get("postExitCommand")).map(JsonElement::getAsString).orElse("")); vs.setServerIp(Optional.ofNullable(obj.get("serverIp")).map(JsonElement::getAsString).orElse("")); - vs.setJava(Optional.ofNullable(obj.get("java")).map(JsonElement::getAsString).orElse("")); vs.setWrapper(Optional.ofNullable(obj.get("wrapper")).map(JsonElement::getAsString).orElse("")); vs.setGameDir(Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse("")); + vs.setNativesDir(Optional.ofNullable(obj.get("nativesDir")).map(JsonElement::getAsString).orElse("")); vs.setFullscreen(Optional.ofNullable(obj.get("fullscreen")).map(JsonElement::getAsBoolean).orElse(false)); vs.setNoJVMArgs(Optional.ofNullable(obj.get("noJVMArgs")).map(JsonElement::getAsBoolean).orElse(false)); vs.setNotCheckGame(Optional.ofNullable(obj.get("notCheckGame")).map(JsonElement::getAsBoolean).orElse(false)); vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false)); + vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false)); vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false)); - vs.setLauncherVisibility(LauncherVisibility.values()[Optional.ofNullable(obj.get("launcherVisibility")).map(JsonElement::getAsInt).orElse(1)]); - vs.setGameDirType(EnumGameDirectory.values()[Optional.ofNullable(obj.get("gameDirType")).map(JsonElement::getAsInt).orElse(0)]); + vs.setLauncherVisibility(parseJsonPrimitive(obj.getAsJsonPrimitive("launcherVisibility"), LauncherVisibility.class, LauncherVisibility.HIDE)); + vs.setProcessPriority(parseJsonPrimitive(obj.getAsJsonPrimitive("processPriority"), ProcessPriority.class, ProcessPriority.NORMAL)); + vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false)); + vs.setUseNativeOpenAL(Optional.ofNullable(obj.get("useNativeOpenAL")).map(JsonElement::getAsBoolean).orElse(false)); + vs.setGameDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("gameDirType"), GameDirectoryType.class, GameDirectoryType.ROOT_FOLDER)); vs.setDefaultJavaPath(Optional.ofNullable(obj.get("defaultJavaPath")).map(JsonElement::getAsString).orElse(null)); + vs.setNativesDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("nativesDirType"), NativesDirectoryType.class, NativesDirectoryType.VERSION_FOLDER)); + vs.setVersionIcon(parseJsonPrimitive(obj.getAsJsonPrimitive("versionIcon"), VersionIconType.class, VersionIconType.DEFAULT)); + + if (obj.get("javaVersionType") != null) { + JavaVersionType javaVersionType = parseJsonPrimitive(obj.getAsJsonPrimitive("javaVersionType"), JavaVersionType.class, JavaVersionType.AUTO); + vs.setJavaVersionType(javaVersionType); + vs.setJavaVersion(Optional.ofNullable(obj.get("java")).map(JsonElement::getAsString).orElse(null)); + } else { + String java = Optional.ofNullable(obj.get("java")).map(JsonElement::getAsString).orElse(""); + switch (java) { + case "Default": + vs.setJavaVersionType(JavaVersionType.DEFAULT); + break; + case "Auto": + vs.setJavaVersionType(JavaVersionType.AUTO); + break; + case "Custom": + vs.setJavaVersionType(JavaVersionType.CUSTOM); + break; + default: + vs.setJavaVersion(java); + } + } + + vs.setRenderer(Optional.ofNullable(obj.get("renderer")).map(JsonElement::getAsString) + .flatMap(name -> { + try { + return Optional.of(Renderer.valueOf(name.toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + }).orElseGet(() -> { + boolean useSoftwareRenderer = Optional.ofNullable(obj.get("useSoftwareRenderer")).map(JsonElement::getAsBoolean).orElse(false); + return useSoftwareRenderer ? Renderer.LLVMPIPE : Renderer.DEFAULT; + })); return vs; } @@ -637,5 +902,25 @@ else if (primitive.isNumber()) else return Lang.parseInt(primitive.getAsString(), defaultValue); } + + private > E parseJsonPrimitive(JsonPrimitive primitive, Class clazz, E defaultValue) { + if (primitive == null) + return defaultValue; + else { + E[] enumConstants = clazz.getEnumConstants(); + if (primitive.isNumber()) { + int index = primitive.getAsInt(); + return index >= 0 && index < enumConstants.length ? enumConstants[index] : defaultValue; + } else { + String name = primitive.getAsString(); + for (E enumConstant : enumConstants) { + if (enumConstant.name().equalsIgnoreCase(name)) { + return enumConstant; + } + } + return defaultValue; + } + } + } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index 3dcd9dc76f..8a0b682a0a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,61 +17,100 @@ */ package org.jackhuang.hmcl.ui; -import com.jfoenix.concurrency.JFXUtilities; +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXDialogLayout; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.beans.InvalidationListener; +import javafx.beans.WeakInvalidationListener; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.ReadOnlyDoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.geometry.Rectangle2D; import javafx.scene.Node; import javafx.scene.Scene; -import javafx.scene.image.Image; +import javafx.scene.control.ButtonBase; +import javafx.scene.control.Label; import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import javafx.stage.Screen; import javafx.stage.Stage; +import javafx.stage.StageStyle; +import javafx.util.Duration; import org.jackhuang.hmcl.Launcher; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.game.HMCLGameRepository; -import org.jackhuang.hmcl.game.Version; -import org.jackhuang.hmcl.setting.Accounts; -import org.jackhuang.hmcl.setting.EnumCommonDirectory; -import org.jackhuang.hmcl.setting.Profiles; +import org.jackhuang.hmcl.game.ModpackHelper; +import org.jackhuang.hmcl.java.JavaManager; +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; -import org.jackhuang.hmcl.ui.account.AccountList; -import org.jackhuang.hmcl.ui.account.AuthlibInjectorServersPage; +import org.jackhuang.hmcl.ui.account.AccountListPage; +import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.ui.construct.*; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; import org.jackhuang.hmcl.ui.decorator.DecoratorController; +import org.jackhuang.hmcl.ui.download.DownloadPage; import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider; -import org.jackhuang.hmcl.ui.profile.ProfileList; -import org.jackhuang.hmcl.ui.versions.GameItem; -import org.jackhuang.hmcl.ui.versions.GameList; +import org.jackhuang.hmcl.ui.main.LauncherSettingsPage; +import org.jackhuang.hmcl.ui.main.RootPage; +import org.jackhuang.hmcl.ui.versions.GameListPage; import org.jackhuang.hmcl.ui.versions.VersionPage; -import org.jackhuang.hmcl.upgrade.UpdateChecker; -import org.jackhuang.hmcl.util.FutureCallback; -import org.jackhuang.hmcl.util.Logging; +import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.javafx.MultiStepBinding; -import org.jackhuang.hmcl.util.platform.JavaVersion; -import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jackhuang.hmcl.util.platform.Architecture; +import org.jackhuang.hmcl.util.platform.OperatingSystem; -import java.io.File; -import java.util.Comparator; -import java.util.Date; +import java.nio.file.Path; import java.util.List; -import java.util.function.Consumer; -import java.util.stream.Collectors; +import java.util.concurrent.CompletableFuture; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.ConfigHolder.*; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class Controllers { + public static final String JAVA_VERSION_TIP = "javaVersion"; + public static final String JAVA_INTERPRETED_MODE_TIP = "javaInterpretedMode"; + public static final String SOFTWARE_RENDERING = "softwareRendering"; + + public static final int MIN_WIDTH = 800 + 2 + 16; // bg width + border width*2 + shadow width*2 + public static final int MIN_HEIGHT = 450 + 2 + 40 + 16; // bg height + border width*2 + toolbar height + shadow width*2 + public static final Screen SCREEN = Screen.getPrimary(); + private static InvalidationListener stageSizeChangeListener; + private static DoubleProperty stageX = new SimpleDoubleProperty(); + private static DoubleProperty stageY = new SimpleDoubleProperty(); + private static DoubleProperty stageWidth = new SimpleDoubleProperty(); + private static DoubleProperty stageHeight = new SimpleDoubleProperty(); private static Scene scene; private static Stage stage; - private static MainPage mainPage = null; - private static SettingsPage settingsPage = null; - private static VersionPage versionPage = null; - private static GameList gameListPage = null; - private static AccountList accountListPage = null; - private static ProfileList profileListPage = null; - private static AuthlibInjectorServersPage serversPage = null; - private static LeftPaneController leftPaneController; + private static Lazy versionPage = new Lazy<>(VersionPage::new); + private static Lazy gameListPage = new Lazy<>(() -> { + GameListPage gameListPage = new GameListPage(); + gameListPage.selectedProfileProperty().bindBidirectional(Profiles.selectedProfileProperty()); + gameListPage.profilesProperty().bindContent(Profiles.profilesProperty()); + FXUtils.applyDragListener(gameListPage, ModpackHelper::isFileModpackByExtension, modpacks -> { + Path modpack = modpacks.get(0); + Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(Profiles.getSelectedProfile(), modpack), i18n("install.modpack")); + }); + return gameListPage; + }); + private static Lazy rootPage = new Lazy<>(RootPage::new); private static DecoratorController decorator; + private static Lazy downloadPage = new Lazy<>(DownloadPage::new); + private static Lazy accountListPage = new Lazy<>(() -> { + AccountListPage accountListPage = new AccountListPage(); + accountListPage.selectedAccountProperty().bindBidirectional(Accounts.selectedAccountProperty()); + accountListPage.accountsProperty().bindContent(Accounts.getAccounts()); + accountListPage.authServersProperty().bindContentBidirectional(config().getAuthlibInjectorServers()); + return accountListPage; + }); + private static Lazy settingsPage = new Lazy<>(LauncherSettingsPage::new); + + private Controllers() { + } public static Scene getScene() { return scene; @@ -82,58 +121,33 @@ public static Stage getStage() { } // FXThread - public static SettingsPage getSettingsPage() { - if (settingsPage == null) - settingsPage = new SettingsPage(); - return settingsPage; + public static VersionPage getVersionPage() { + return versionPage.get(); } // FXThread - public static GameList getGameListPage() { - if (gameListPage == null) { - gameListPage = new GameList(); - FXUtils.applyDragListener(gameListPage, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> { - File modpack = modpacks.get(0); - Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(Profiles.getSelectedProfile(), modpack), i18n("install.modpack")); - }); - } - return gameListPage; + public static GameListPage getGameListPage() { + return gameListPage.get(); } // FXThread - public static AccountList getAccountListPage() { - if (accountListPage == null) { - AccountList accountListPage = new AccountList(); - accountListPage.selectedAccountProperty().bindBidirectional(Accounts.selectedAccountProperty()); - accountListPage.accountsProperty().bindContent(Accounts.accountsProperty()); - Controllers.accountListPage = accountListPage; - } - return accountListPage; + public static RootPage getRootPage() { + return rootPage.get(); } // FXThread - public static ProfileList getProfileListPage() { - if (profileListPage == null) { - ProfileList profileListPage = new ProfileList(); - profileListPage.selectedProfileProperty().bindBidirectional(Profiles.selectedProfileProperty()); - profileListPage.profilesProperty().bindContent(Profiles.profilesProperty()); - Controllers.profileListPage = profileListPage; - } - return profileListPage; + public static LauncherSettingsPage getSettingsPage() { + return settingsPage.get(); } // FXThread - public static VersionPage getVersionPage() { - if (versionPage == null) - versionPage = new VersionPage(); - return versionPage; + public static AccountListPage getAccountListPage() { + return accountListPage.get(); } // FXThread - public static AuthlibInjectorServersPage getServersPage() { - if (serversPage == null) - serversPage = new AuthlibInjectorServersPage(); - return serversPage; + public static DownloadPage getDownloadPage() { + return downloadPage.get(); } // FXThread @@ -141,62 +155,122 @@ public static DecoratorController getDecorator() { return decorator; } - public static MainPage getMainPage() { - if (mainPage == null) { - MainPage mainPage = new MainPage(); - FXUtils.applyDragListener(mainPage, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> { - File modpack = modpacks.get(0); - Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(Profiles.getSelectedProfile(), modpack), i18n("install.modpack")); - }); - - FXUtils.onChangeAndOperate(Profiles.selectedVersionProperty(), version -> { - if (version != null) { - mainPage.setCurrentGame(version); - } else { - mainPage.setCurrentGame(i18n("version.empty")); - } - }); - mainPage.showUpdateProperty().bind(UpdateChecker.outdatedProperty()); - mainPage.latestVersionProperty().bind( - MultiStepBinding.of(UpdateChecker.latestVersionProperty()) - .map(version -> version == null ? "" : i18n("update.bubble.title", version.getVersion()))); - - Profiles.registerVersionsListener(profile -> { - HMCLGameRepository repository = profile.getRepository(); - List children = repository.getVersions().parallelStream() - .filter(version -> !version.isHidden()) - .sorted(Comparator.comparing((Version version) -> version.getReleaseTime() == null ? new Date(0L) : version.getReleaseTime()) - .thenComparing(a -> VersionNumber.asVersion(a.getId()))) - .map(version -> { - Node node = PopupMenu.wrapPopupMenuItem(new GameItem(profile, version.getId())); - node.setOnMouseClicked(e -> profile.setSelectedVersion(version.getId())); - return node; - }) - .collect(Collectors.toList()); - JFXUtilities.runInFX(() -> { - if (profile == Profiles.getSelectedProfile()) - mainPage.getVersions().setAll(children); - }); - }); - Controllers.mainPage = mainPage; + public static void onApplicationStop() { + stageSizeChangeListener = null; + if (stageX != null) { + config().setX(stageX.get() / SCREEN.getBounds().getWidth()); + stageX = null; + } + if (stageY != null) { + config().setY(stageY.get() / SCREEN.getBounds().getHeight()); + stageY = null; + } + if (stageHeight != null) { + config().setHeight(stageHeight.get()); + stageHeight = null; + } + if (stageWidth != null) { + config().setWidth(stageWidth.get()); + stageWidth = null; } - return mainPage; - } - - public static LeftPaneController getLeftPaneController() { - return leftPaneController; } public static void initialize(Stage stage) { - Logging.LOG.info("Start initializing application"); + LOG.info("Start initializing application"); + + if (System.getProperty("prism.lcdtext") == null) { + String fontAntiAliasing = globalConfig().getFontAntiAliasing(); + if ("lcd".equalsIgnoreCase(fontAntiAliasing)) { + LOG.info("Enable sub-pixel antialiasing"); + System.getProperties().put("prism.lcdtext", "true"); + } else if ("gray".equalsIgnoreCase(fontAntiAliasing) + || OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && SCREEN.getOutputScaleX() > 1) { + LOG.info("Disable sub-pixel antialiasing"); + System.getProperties().put("prism.lcdtext", "false"); + } + } Controllers.stage = stage; + stageSizeChangeListener = o -> { + ReadOnlyDoubleProperty sourceProperty = (ReadOnlyDoubleProperty) o; + DoubleProperty targetProperty; + switch (sourceProperty.getName()) { + case "x": { + targetProperty = stageX; + break; + } + case "y": { + targetProperty = stageY; + break; + } + case "width": { + targetProperty = stageWidth; + break; + } + case "height": { + targetProperty = stageHeight; + break; + } + default: { + targetProperty = null; + } + } + + if (targetProperty != null + && Controllers.stage != null + && !Controllers.stage.isIconified() + // https://github.com/HMCL-dev/HMCL/issues/4290 + && (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS || + !Controllers.stage.isFullScreen() && !Controllers.stage.isMaximized()) + ) { + targetProperty.set(sourceProperty.get()); + } + }; + + WeakInvalidationListener weakListener = new WeakInvalidationListener(stageSizeChangeListener); + + double initWidth = Math.max(MIN_WIDTH, config().getWidth()); + double initHeight = Math.max(MIN_HEIGHT, config().getHeight()); + + { + double initX = config().getX() * SCREEN.getBounds().getWidth(); + double initY = config().getY() * SCREEN.getBounds().getHeight(); + + boolean invalid = true; + double border = 20D; + for (Screen screen : Screen.getScreens()) { + Rectangle2D bound = screen.getBounds(); + + if (bound.getMinX() + border <= initX + initWidth && initX <= bound.getMaxX() - border && bound.getMinY() + border <= initY && initY <= bound.getMaxY() - border) { + invalid = false; + break; + } + } + + if (invalid) { + initX = (0.5D - initWidth / SCREEN.getBounds().getWidth() / 2) * SCREEN.getBounds().getWidth(); + initY = (0.5D - initHeight / SCREEN.getBounds().getHeight() / 2) * SCREEN.getBounds().getHeight(); + } + + stage.setX(initX); + stage.setY(initY); + stageX.set(initX); + stageY.set(initY); + } + + stage.setHeight(initHeight); + stage.setWidth(initWidth); + stageHeight.set(initHeight); + stageWidth.set(initWidth); + stage.xProperty().addListener(weakListener); + stage.yProperty().addListener(weakListener); + stage.heightProperty().addListener(weakListener); + stage.widthProperty().addListener(weakListener); + stage.setOnCloseRequest(e -> Launcher.stopApplication()); - decorator = new DecoratorController(stage, getMainPage()); - leftPaneController = new LeftPaneController(); - decorator.getDecorator().drawerProperty().setAll(leftPaneController); + decorator = new DecoratorController(stage, getRootPage()); if (config().getCommonDirType() == EnumCommonDirectory.CUSTOM && !FileUtils.canCreateDirectory(config().getCommonDirectory())) { @@ -204,13 +278,113 @@ public static void initialize(Stage stage) { dialog(i18n("launcher.cache_directory.invalid")); } - Task.of(JavaVersion::initialize).start(); + Lang.thread(JavaManager::initialize, "Search Java", true); + + scene = new Scene(decorator.getDecorator()); + scene.setFill(Color.TRANSPARENT); + stage.setMinWidth(MIN_WIDTH); + stage.setMinHeight(MIN_HEIGHT); + decorator.getDecorator().prefWidthProperty().bind(scene.widthProperty()); + decorator.getDecorator().prefHeightProperty().bind(scene.heightProperty()); + StyleSheets.init(scene); + + FXUtils.setIcon(stage); + stage.setTitle(Metadata.FULL_TITLE); + stage.initStyle(StageStyle.TRANSPARENT); + stage.setScene(scene); + + if (AnimationUtils.playWindowAnimation()) { + Timeline timeline = new Timeline( + new KeyFrame(Duration.millis(0), + new KeyValue(decorator.getDecorator().opacityProperty(), 0, FXUtils.EASE), + new KeyValue(decorator.getDecorator().scaleXProperty(), 0.8, FXUtils.EASE), + new KeyValue(decorator.getDecorator().scaleYProperty(), 0.8, FXUtils.EASE), + new KeyValue(decorator.getDecorator().scaleZProperty(), 0.8, FXUtils.EASE) + ), + new KeyFrame(Duration.millis(600), + new KeyValue(decorator.getDecorator().opacityProperty(), 1, FXUtils.EASE), + new KeyValue(decorator.getDecorator().scaleXProperty(), 1, FXUtils.EASE), + new KeyValue(decorator.getDecorator().scaleYProperty(), 1, FXUtils.EASE), + new KeyValue(decorator.getDecorator().scaleZProperty(), 1, FXUtils.EASE) + ) + ); + timeline.play(); + } - scene = new Scene(decorator.getDecorator(), 800, 519); - scene.getStylesheets().setAll(config().getTheme().getStylesheets()); + if (!Architecture.SYSTEM_ARCH.isX86() && globalConfig().getPlatformPromptVersion() < 1) { + Runnable continueAction = () -> globalConfig().setPlatformPromptVersion(1); + + if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.SYSTEM_ARCH == Architecture.ARM64) { + Controllers.dialog(i18n("fatal.unsupported_platform.macos_arm64"), null, MessageType.INFO, continueAction); + } else if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH == Architecture.ARM64) { + Controllers.dialog(i18n("fatal.unsupported_platform.windows_arm64"), null, MessageType.INFO, continueAction); + } else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && + (Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64 + || Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64_OW + || Architecture.SYSTEM_ARCH == Architecture.MIPS64EL)) { + Controllers.dialog(i18n("fatal.unsupported_platform.loongarch"), null, MessageType.INFO, continueAction); + } else { + Controllers.dialog(i18n("fatal.unsupported_platform"), null, MessageType.WARNING, continueAction); + } + } + + if (JavaRuntime.CURRENT_VERSION < Metadata.MINIMUM_SUPPORTED_JAVA_VERSION) { + Number shownTipVersion = null; + try { + shownTipVersion = (Number) config().getShownTips().get(JAVA_VERSION_TIP); + } catch (ClassCastException e) { + LOG.warning("Invalid type for shown tips key: " + JAVA_VERSION_TIP, e); + } + if (shownTipVersion == null || shownTipVersion.intValue() < Metadata.MINIMUM_SUPPORTED_JAVA_VERSION) { + MessageDialogPane.Builder builder = new MessageDialogPane.Builder(i18n("fatal.deprecated_java_version"), null, MessageType.WARNING); + String downloadLink = Metadata.getSuggestedJavaDownloadLink(); + if (downloadLink != null) + builder.addHyperLink( + i18n("fatal.deprecated_java_version.download_link", Metadata.RECOMMENDED_JAVA_VERSION), + downloadLink + ); + Controllers.dialog(builder + .ok(() -> config().getShownTips().put(JAVA_VERSION_TIP, Metadata.MINIMUM_SUPPORTED_JAVA_VERSION)) + .build()); + } + } + + // Check whether JIT is enabled in the current environment + if (!JavaRuntime.CURRENT_JIT_ENABLED && !Boolean.TRUE.equals(config().getShownTips().get(JAVA_INTERPRETED_MODE_TIP))) { + Controllers.dialog(new MessageDialogPane.Builder(i18n("warning.java_interpreted_mode"), i18n("message.warning"), MessageType.WARNING) + .ok(null) + .addCancel(i18n("button.do_not_show_again"), () -> + config().getShownTips().put(JAVA_INTERPRETED_MODE_TIP, true)) + .build()); + } - stage.getIcons().add(new Image("/assets/img/icon.png")); - stage.setTitle(Metadata.TITLE); + // Check whether hardware acceleration is enabled + if (!FXUtils.GPU_ACCELERATION_ENABLED && !Boolean.TRUE.equals(config().getShownTips().get(SOFTWARE_RENDERING))) { + Controllers.dialog(new MessageDialogPane.Builder(i18n("warning.software_rendering"), i18n("message.warning"), MessageType.WARNING) + .ok(null) + .addCancel(i18n("button.do_not_show_again"), () -> + config().getShownTips().put(SOFTWARE_RENDERING, true)) + .build()); + } + + if (globalConfig().getAgreementVersion() < 1) { + JFXDialogLayout agreementPane = new JFXDialogLayout(); + agreementPane.setHeading(new Label(i18n("launcher.agreement"))); + agreementPane.setBody(new Label(i18n("launcher.agreement.hint"))); + JFXHyperlink agreementLink = new JFXHyperlink(i18n("launcher.agreement")); + agreementLink.setExternalLink(Metadata.EULA_URL); + JFXButton yesButton = new JFXButton(i18n("launcher.agreement.accept")); + yesButton.getStyleClass().add("dialog-accept"); + yesButton.setOnAction(e -> { + globalConfig().setAgreementVersion(1); + agreementPane.fireEvent(new DialogCloseEvent()); + }); + JFXButton noButton = new JFXButton(i18n("launcher.agreement.decline")); + noButton.getStyleClass().add("dialog-cancel"); + noButton.setOnAction(e -> javafx.application.Platform.exit()); + agreementPane.setActions(agreementLink, yesButton, noButton); + Controllers.dialog(agreementPane); + } } public static void dialog(Region content) { @@ -223,46 +397,83 @@ public static void dialog(String text) { } public static void dialog(String text, String title) { - dialog(text, title, MessageBox.INFORMATION_MESSAGE); + dialog(text, title, MessageType.INFO); } - public static void dialog(String text, String title, int type) { + public static void dialog(String text, String title, MessageType type) { dialog(text, title, type, null); } - public static void dialog(String text, String title, int type, Runnable onAccept) { - dialog(new MessageDialogPane(text, title, type, onAccept)); + public static void dialog(String text, String title, MessageType type, Runnable ok) { + dialog(new MessageDialogPane.Builder(text, title, type).ok(ok).build()); } - public static void confirmDialog(String text, String title, Runnable onAccept, Runnable onCancel) { - dialog(new MessageDialogPane(text, title, onAccept, onCancel)); + public static void confirm(String text, String title, Runnable yes, Runnable no) { + confirm(text, title, MessageType.QUESTION, yes, no); } - public static InputDialogPane inputDialog(String text, FutureCallback onResult) { - InputDialogPane pane = new InputDialogPane(text, onResult); - dialog(pane); - return pane; + public static void confirm(String text, String title, MessageType type, Runnable yes, Runnable no) { + dialog(new MessageDialogPane.Builder(text, title, type).yesOrNo(yes, no).build()); + } + + public static void confirmAction(String text, String title, MessageType type, ButtonBase actionButton) { + dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, null).build()); + } + + public static void confirmAction(String text, String title, MessageType type, ButtonBase actionButton, Runnable cancel) { + dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, cancel).build()); + } + + public static CompletableFuture prompt(String title, FutureCallback onResult) { + return prompt(title, onResult, ""); } - public static Region taskDialog(TaskExecutor executor, String title) { - return taskDialog(executor, title, ""); + public static CompletableFuture prompt(String title, FutureCallback onResult, String initialValue) { + InputDialogPane pane = new InputDialogPane(title, initialValue, onResult); + dialog(pane); + return pane.getCompletableFuture(); } - public static Region taskDialog(TaskExecutor executor, String title, String subtitle) { - return taskDialog(executor, title, subtitle, null); + public static CompletableFuture>> prompt(PromptDialogPane.Builder builder) { + PromptDialogPane pane = new PromptDialogPane(builder); + dialog(pane); + return pane.getCompletableFuture(); } - public static Region taskDialog(TaskExecutor executor, String title, String subtitle, Consumer onCancel) { + public static TaskExecutorDialogPane taskDialog(TaskExecutor executor, String title, TaskCancellationAction onCancel) { TaskExecutorDialogPane pane = new TaskExecutorDialogPane(onCancel); pane.setTitle(title); - pane.setSubtitle(subtitle); pane.setExecutor(executor); dialog(pane); return pane; } + public static TaskExecutorDialogPane taskDialog(Task task, String title, TaskCancellationAction onCancel) { + TaskExecutor executor = task.executor(); + TaskExecutorDialogPane pane = taskDialog(executor, title, onCancel); + executor.start(); + return pane; + } + public static void navigate(Node node) { - decorator.getNavigator().navigate(node); + decorator.navigate(node); + } + + public static void showToast(String content) { + decorator.showToast(content); + } + + public static void onHyperlinkAction(String href) { + if (href.startsWith("hmcl://")) { + switch (href) { + case "hmcl://settings/feedback": + Controllers.getSettingsPage().showFeedback(); + Controllers.navigate(Controllers.getSettingsPage()); + break; + } + } else { + FXUtils.openLink(href); + } } public static boolean isStopped() { @@ -270,15 +481,17 @@ public static boolean isStopped() { } public static void shutdown() { - mainPage = null; - settingsPage = null; + rootPage = null; versionPage = null; - serversPage = null; + gameListPage = null; + downloadPage = null; + accountListPage = null; + settingsPage = null; decorator = null; stage = null; scene = null; - gameListPage = null; - accountListPage = null; - profileListPage = null; + onApplicationStop(); + + FXUtils.shutdown(); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/CrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/CrashWindow.java index 7d1870d2b9..5af27b6a60 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/CrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/CrashWindow.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,12 +22,12 @@ import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.TextArea; -import javafx.scene.image.Image; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.stage.Stage; import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.countly.CrashReport; import org.jackhuang.hmcl.upgrade.UpdateChecker; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -37,21 +37,23 @@ */ public class CrashWindow extends Stage { - public CrashWindow(String text) { + public CrashWindow(CrashReport report) { Label lblCrash = new Label(); - if (UpdateChecker.isOutdated()) - lblCrash.setText(i18n("launcher.crash_out_dated")); + if (report.getThrowable() instanceof InternalError) + lblCrash.setText(i18n("launcher.crash.java_internal_error")); + else if (UpdateChecker.isOutdated()) + lblCrash.setText(i18n("launcher.crash.hmcl_out_dated")); else lblCrash.setText(i18n("launcher.crash")); lblCrash.setWrapText(true); TextArea textArea = new TextArea(); - textArea.setText(text); + textArea.setText(report.getDisplayText()); textArea.setEditable(false); Button btnContact = new Button(); btnContact.setText(i18n("launcher.contact")); - btnContact.setOnMouseClicked(event -> FXUtils.openLink(Metadata.CONTACT_URL)); + btnContact.setOnAction(event -> FXUtils.openLink(Metadata.CONTACT_URL)); HBox box = new HBox(); box.setStyle("-fx-padding: 8px;"); box.getChildren().add(btnContact); @@ -67,10 +69,10 @@ public CrashWindow(String text) { Scene scene = new Scene(pane, 800, 480); setScene(scene); - getIcons().add(new Image("/assets/img/icon.png")); + FXUtils.setIcon(this); setTitle(i18n("message.error")); - setOnCloseRequest(e -> System.exit(1)); + setOnCloseRequest(e -> javafx.application.Platform.exit()); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/DialogController.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/DialogController.java index 51259aacde..9fce1daf92 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/DialogController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/DialogController.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,32 +17,46 @@ */ package org.jackhuang.hmcl.ui; -import com.jfoenix.concurrency.JFXUtilities; -import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.auth.AuthInfo; -import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; -import org.jackhuang.hmcl.task.SilentException; -import org.jackhuang.hmcl.ui.account.AccountLoginPane; +import org.jackhuang.hmcl.auth.*; +import org.jackhuang.hmcl.ui.account.ClassicAccountLoginDialog; +import org.jackhuang.hmcl.ui.account.OAuthAccountLoginDialog; import java.util.Optional; +import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; +import static org.jackhuang.hmcl.ui.FXUtils.runInFX; + public final class DialogController { + private DialogController() { + } - public static AuthInfo logIn(Account account) throws Exception { - if (account instanceof YggdrasilAccount) { + public static AuthInfo logIn(Account account) throws CancellationException, AuthenticationException, InterruptedException { + if (account instanceof ClassicAccount) { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference res = new AtomicReference<>(null); + runInFX(() -> { + ClassicAccountLoginDialog pane = new ClassicAccountLoginDialog((ClassicAccount) account, it -> { + res.set(it); + latch.countDown(); + }, latch::countDown); + Controllers.dialog(pane); + }); + latch.await(); + return Optional.ofNullable(res.get()).orElseThrow(CancellationException::new); + } else if (account instanceof OAuthAccount) { CountDownLatch latch = new CountDownLatch(1); AtomicReference res = new AtomicReference<>(null); - JFXUtilities.runInFX(() -> { - AccountLoginPane pane = new AccountLoginPane(account, it -> { - res.set(it); - latch.countDown(); + runInFX(() -> { + OAuthAccountLoginDialog pane = new OAuthAccountLoginDialog((OAuthAccount) account, it -> { + res.set(it); + latch.countDown(); }, latch::countDown); Controllers.dialog(pane); }); latch.await(); - return Optional.ofNullable(res.get()).orElseThrow(SilentException::new); + return Optional.ofNullable(res.get()).orElseThrow(CancellationException::new); } return account.logIn(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java index 614ee02958..84cfaf1b7d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,63 +17,203 @@ */ package org.jackhuang.hmcl.ui; -import com.jfoenix.concurrency.JFXUtilities; import com.jfoenix.controls.*; -import javafx.animation.Animation; -import javafx.animation.Interpolator; -import javafx.animation.KeyFrame; -import javafx.animation.Timeline; +import javafx.animation.*; import javafx.application.Platform; import javafx.beans.InvalidationListener; -import javafx.beans.property.Property; -import javafx.beans.value.ChangeListener; -import javafx.beans.value.ObservableValue; -import javafx.beans.value.WeakChangeListener; -import javafx.event.EventHandler; -import javafx.fxml.FXMLLoader; +import javafx.beans.Observable; +import javafx.beans.WeakInvalidationListener; +import javafx.beans.WeakListener; +import javafx.beans.binding.Bindings; +import javafx.beans.property.*; +import javafx.beans.value.*; +import javafx.collections.ObservableMap; +import javafx.event.Event; +import javafx.event.EventDispatcher; +import javafx.event.EventType; +import javafx.geometry.Bounds; import javafx.geometry.Pos; +import javafx.geometry.Rectangle2D; +import javafx.scene.Cursor; import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; import javafx.scene.control.*; +import javafx.scene.image.Image; import javafx.scene.image.ImageView; -import javafx.scene.input.MouseEvent; -import javafx.scene.input.ScrollEvent; -import javafx.scene.input.TransferMode; -import javafx.scene.layout.Region; -import javafx.scene.layout.StackPane; +import javafx.scene.input.*; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; import javafx.scene.shape.Rectangle; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; +import javafx.stage.*; import javafx.util.Callback; import javafx.util.Duration; import javafx.util.StringConverter; +import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.task.CacheFileTask; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.animation.AnimationUtils; +import org.jackhuang.hmcl.ui.image.ImageLoader; +import org.jackhuang.hmcl.ui.image.ImageUtils; import org.jackhuang.hmcl.util.*; -import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.javafx.ExtendedProperties; +import org.jackhuang.hmcl.util.javafx.SafeStringConverter; import org.jackhuang.hmcl.util.platform.OperatingSystem; - +import org.jackhuang.hmcl.util.platform.SystemUtils; +import org.jetbrains.annotations.Nullable; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.BufferedInputStream; import java.io.File; -import java.io.FileFilter; import java.io.IOException; -import java.io.UncheckedIOException; -import java.lang.reflect.Constructor; -import java.lang.reflect.Method; -import java.net.URI; +import java.io.StringReader; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.ref.WeakReference; +import java.net.*; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BooleanSupplier; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Supplier; -import java.util.logging.Level; -import java.util.stream.Collectors; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static org.jackhuang.hmcl.util.Lang.thread; import static org.jackhuang.hmcl.util.Lang.tryCast; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class FXUtils { private FXUtils() { } + public static final int JAVAFX_MAJOR_VERSION; + + public static final String GRAPHICS_PIPELINE; + public static final boolean GPU_ACCELERATION_ENABLED; + + static { + String pipelineName = ""; + + try { + Object pipeline = Class.forName("com.sun.prism.GraphicsPipeline").getMethod("getPipeline").invoke(null); + if (pipeline != null) { + pipelineName = pipeline.getClass().getName(); + } + } catch (Throwable e) { + LOG.warning("Failed to get prism pipeline", e); + } + + GRAPHICS_PIPELINE = pipelineName; + GPU_ACCELERATION_ENABLED = !pipelineName.endsWith(".SWPipeline"); + } + + /// @see Platform.Preferences + public static final @Nullable ObservableMap PREFERENCES; + public static final @Nullable ObservableBooleanValue DARK_MODE; + public static final @Nullable Boolean REDUCED_MOTION; + + public static final @Nullable MethodHandle TEXT_TRUNCATED_PROPERTY; + + static { + String jfxVersion = System.getProperty("javafx.version"); + int majorVersion = -1; + if (jfxVersion != null) { + Matcher matcher = Pattern.compile("^(?[0-9]+)").matcher(jfxVersion); + if (matcher.find()) { + majorVersion = Lang.parseInt(matcher.group(), -1); + } + } + JAVAFX_MAJOR_VERSION = majorVersion; + + ObservableMap preferences = null; + ObservableBooleanValue darkMode = null; + Boolean reducedMotion = null; + if (JAVAFX_MAJOR_VERSION >= 22) { + try { + MethodHandles.Lookup lookup = MethodHandles.publicLookup(); + Class preferencesClass = Class.forName("javafx.application.Platform$Preferences"); + @SuppressWarnings("unchecked") + var preferences0 = (ObservableMap) lookup.findStatic(Platform.class, "getPreferences", MethodType.methodType(preferencesClass)) + .invoke(); + preferences = preferences0; + + @SuppressWarnings("unchecked") + var colorSchemeProperty = + (ReadOnlyObjectProperty>) + lookup.findVirtual(preferencesClass, "colorSchemeProperty", MethodType.methodType(ReadOnlyObjectProperty.class)) + .invoke(preferences); + + darkMode = Bindings.createBooleanBinding(() -> + "DARK".equals(colorSchemeProperty.get().name()), colorSchemeProperty); + + if (JAVAFX_MAJOR_VERSION >= 24) { + reducedMotion = (boolean) + lookup.findVirtual(preferencesClass, "isReducedMotion", MethodType.methodType(boolean.class)) + .invoke(preferences); + } + } catch (Throwable e) { + LOG.warning("Failed to get preferences", e); + } + } + PREFERENCES = preferences; + DARK_MODE = darkMode; + REDUCED_MOTION = reducedMotion; + + MethodHandle textTruncatedProperty = null; + if (JAVAFX_MAJOR_VERSION >= 23) { + try { + textTruncatedProperty = MethodHandles.publicLookup().findVirtual( + Labeled.class, + "textTruncatedProperty", + MethodType.methodType(ReadOnlyBooleanProperty.class) + ); + } catch (Throwable e) { + LOG.warning("Failed to lookup textTruncatedProperty", e); + } + } + TEXT_TRUNCATED_PROPERTY = textTruncatedProperty; + } + + public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace"; + + public static final List IMAGE_EXTENSIONS = Lang.immutableListOf( + "png", "jpg", "jpeg", "bmp", "gif", "webp", "apng" + ); + + private static final Map builtinImageCache = new ConcurrentHashMap<>(); + + public static void shutdown() { + builtinImageCache.clear(); + } + + public static void runInFX(Runnable runnable) { + if (Platform.isFxApplicationThread()) { + runnable.run(); + } else { + Platform.runLater(runnable); + } + } + public static void checkFxUserThread() { if (!Platform.isFxApplicationThread()) { throw new IllegalStateException("Not on FX application thread; currentThread = " @@ -89,8 +229,10 @@ public static void onChange(ObservableValue value, Consumer consumer) value.addListener((a, b, c) -> consumer.accept(c)); } - public static void onWeakChange(ObservableValue value, Consumer consumer) { - value.addListener(new WeakChangeListener<>((a, b, c) -> consumer.accept(c))); + public static ChangeListener onWeakChange(ObservableValue value, Consumer consumer) { + ChangeListener listener = (a, b, c) -> consumer.accept(c); + value.addListener(new WeakChangeListener<>(listener)); + return listener; } public static void onChangeAndOperate(ObservableValue value, Consumer consumer) { @@ -98,9 +240,19 @@ public static void onChangeAndOperate(ObservableValue value, Consumer onChange(value, consumer); } - public static void onWeakChangeAndOperate(ObservableValue value, Consumer consumer) { + public static ChangeListener onWeakChangeAndOperate(ObservableValue value, Consumer consumer) { consumer.accept(value.getValue()); - onWeakChange(value, consumer); + return onWeakChange(value, consumer); + } + + public static InvalidationListener observeWeak(Runnable runnable, Observable... observables) { + InvalidationListener originalListener = observable -> runnable.run(); + WeakInvalidationListener listener = new WeakInvalidationListener(originalListener); + for (Observable observable : observables) { + observable.addListener(listener); + } + runnable.run(); + return originalListener; } public static void runLaterIf(BooleanSupplier condition, Runnable runnable) { @@ -153,6 +305,21 @@ public static void removeListener(Node node, String key) { }); } + @SuppressWarnings("unchecked") + public static void ignoreEvent(Node node, EventType type, Predicate filter) { + EventDispatcher oldDispatcher = node.getEventDispatcher(); + node.setEventDispatcher((event, tail) -> { + EventType t = event.getEventType(); + while (t != null && t != type) + t = t.getSuperType(); + if (t == type && filter.test((T) event)) { + return tail.dispatchEvent(event); + } else { + return oldDispatcher.dispatchEvent(event, tail); + } + }); + } + public static void setValidateWhileTextChanged(Node field, boolean validate) { if (field instanceof JFXTextField) { if (validate) { @@ -176,19 +343,19 @@ public static boolean getValidateWhileTextChanged(Node field) { return field.getProperties().containsKey("FXUtils.validation"); } - public static void setOverflowHidden(Region region, boolean hidden) { - if (hidden) { - Rectangle rectangle = new Rectangle(); - rectangle.widthProperty().bind(region.widthProperty()); - rectangle.heightProperty().bind(region.heightProperty()); - region.setClip(rectangle); - } else { - region.setClip(null); - } + public static Rectangle setOverflowHidden(Region region) { + Rectangle rectangle = new Rectangle(); + rectangle.widthProperty().bind(region.widthProperty()); + rectangle.heightProperty().bind(region.heightProperty()); + region.setClip(rectangle); + return rectangle; } - public static boolean getOverflowHidden(Region region) { - return region.getClip() != null; + public static Rectangle setOverflowHidden(Region region, double arc) { + Rectangle rectangle = setOverflowHidden(region); + rectangle.setArcWidth(arc); + rectangle.setArcHeight(arc); + return rectangle; } public static void setLimitWidth(Region region, double width) { @@ -220,22 +387,39 @@ public static Node limitingSize(Node node, double width, double height) { } public static void smoothScrolling(ScrollPane scrollPane) { - JFXScrollPane.smoothScrolling(scrollPane); + if (AnimationUtils.isAnimationEnabled()) + ScrollUtils.addSmoothScrolling(scrollPane); } - public static void loadFXML(Node node, String absolutePath) { - FXMLLoader loader = new FXMLLoader(node.getClass().getResource(absolutePath), I18n.getResourceBundle()); - loader.setRoot(node); - loader.setController(node); - try { - loader.load(); - } catch (IOException e) { - throw new UncheckedIOException(e); + /// If the current environment is JavaFX 23 or higher, this method returns [Labeled#textTruncatedProperty()]; + /// Otherwise, it returns `null`. + public static @Nullable ReadOnlyBooleanProperty textTruncatedProperty(Labeled labeled) { + if (TEXT_TRUNCATED_PROPERTY != null) { + try { + return (ReadOnlyBooleanProperty) TEXT_TRUNCATED_PROPERTY.invokeExact(labeled); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException(e); + } + } else { + return null; } } + private static final Duration TOOLTIP_FAST_SHOW_DELAY = Duration.millis(50); + private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500); + private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000); + + public static void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) { + tooltip.setShowDelay(showDelay); + tooltip.setShowDuration(showDuration); + tooltip.setHideDelay(hideDelay); + Tooltip.install(node, tooltip); + } + public static void installFastTooltip(Node node, Tooltip tooltip) { - installTooltip(node, 50, 5000, 0, tooltip); + runInFX(() -> installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip)); } public static void installFastTooltip(Node node, String tooltip) { @@ -243,232 +427,845 @@ public static void installFastTooltip(Node node, String tooltip) { } public static void installSlowTooltip(Node node, Tooltip tooltip) { - installTooltip(node, 500, 5000, 0, tooltip); + runInFX(() -> installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip)); } public static void installSlowTooltip(Node node, String tooltip) { installSlowTooltip(node, new Tooltip(tooltip)); } - public static void installTooltip(Node node, double openDelay, double visibleDelay, double closeDelay, Tooltip tooltip) { - JFXUtilities.runInFX(() -> { - try { - // Java 8 - Class behaviorClass = Class.forName("javafx.scene.control.Tooltip$TooltipBehavior"); - Constructor behaviorConstructor = behaviorClass.getDeclaredConstructor(Duration.class, Duration.class, Duration.class, boolean.class); - behaviorConstructor.setAccessible(true); - Object behavior = behaviorConstructor.newInstance(new Duration(openDelay), new Duration(visibleDelay), new Duration(closeDelay), false); - Method installMethod = behaviorClass.getDeclaredMethod("install", Node.class, Tooltip.class); - installMethod.setAccessible(true); - installMethod.invoke(behavior, node, tooltip); - } catch (ReflectiveOperationException e) { + public static void playAnimation(Node node, String animationKey, Timeline timeline) { + animationKey = "FXUTILS.ANIMATION." + animationKey; + Object oldTimeline = node.getProperties().get(animationKey); +// if (oldTimeline instanceof Timeline) ((Timeline) oldTimeline).stop(); + if (timeline != null) timeline.play(); + node.getProperties().put(animationKey, timeline); + } + + public static Animation playAnimation(Node node, String animationKey, Duration duration, WritableValue property, T from, T to, Interpolator interpolator) { + if (from == null) from = property.getValue(); + if (duration == null || Objects.equals(duration, Duration.ZERO) || Objects.equals(from, to)) { + playAnimation(node, animationKey, null); + property.setValue(to); + return null; + } else { + Timeline timeline = new Timeline( + new KeyFrame(Duration.ZERO, new KeyValue(property, from, interpolator)), + new KeyFrame(duration, new KeyValue(property, to, interpolator)) + ); + playAnimation(node, animationKey, timeline); + return timeline; + } + } + + public static void openFolder(Path file) { + if (file.getFileSystem() != FileSystems.getDefault()) { + LOG.warning("Cannot open folder as the file system is not supported: " + file); + return; + } + + try { + Files.createDirectories(file); + } catch (IOException e) { + LOG.warning("Failed to create directory " + file); + return; + } + + String path = FileUtils.getAbsolutePath(file); + + String openCommand; + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) + openCommand = "explorer.exe"; + else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) + openCommand = "/usr/bin/open"; + else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && Files.exists(Path.of("/usr/bin/xdg-open"))) + openCommand = "/usr/bin/xdg-open"; + else + openCommand = null; + + thread(() -> { + if (openCommand != null) { try { - // Java 9 - Tooltip.class.getMethod("setShowDelay", Duration.class).invoke(tooltip, new Duration(openDelay)); - Tooltip.class.getMethod("setShowDuration", Duration.class).invoke(tooltip, new Duration(visibleDelay)); - Tooltip.class.getMethod("setHideDelay", Duration.class).invoke(tooltip, new Duration(closeDelay)); - } catch (ReflectiveOperationException e2) { - e.addSuppressed(e2); - Logging.LOG.log(Level.SEVERE, "Cannot install tooltip", e); + int exitCode = SystemUtils.callExternalProcess(openCommand, path); + + // explorer.exe always return 1 + if (exitCode == 0 || (exitCode == 1 && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)) + return; + else + LOG.warning("Open " + path + " failed with code " + exitCode); + } catch (Throwable e) { + LOG.warning("Unable to open " + path + " by executing " + openCommand, e); } - Tooltip.install(node, tooltip); + } + + // Fallback to java.awt.Desktop::open + try { + java.awt.Desktop.getDesktop().open(file.toFile()); + } catch (Throwable e) { + LOG.error("Unable to open " + path + " by java.awt.Desktop.getDesktop()::open", e); } }); } - public static void openFolder(File file) { - if (!FileUtils.makeDirectory(file)) { - Logging.LOG.log(Level.SEVERE, "Unable to make directory " + file); - return; - } - - String path = file.getAbsolutePath(); + public static void showFileInExplorer(Path file) { + String path = file.toAbsolutePath().toString(); + + String[] openCommands; + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) + openCommands = new String[]{"explorer.exe", "/select,", path}; + else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) + openCommands = new String[]{"/usr/bin/open", "-R", path}; + else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && SystemUtils.which("dbus-send") != null) + openCommands = new String[]{ + "dbus-send", + "--print-reply", + "--dest=org.freedesktop.FileManager1", + "/org/freedesktop/FileManager1", + "org.freedesktop.FileManager1.ShowItems", + "array:string:" + file.toAbsolutePath().toUri(), + "string:" + }; + else + openCommands = null; - switch (OperatingSystem.CURRENT_OS) { - case OSX: + if (openCommands != null) { + thread(() -> { try { - Runtime.getRuntime().exec(new String[]{"/usr/bin/open", path}); - } catch (IOException e) { - Logging.LOG.log(Level.SEVERE, "Unable to open " + path + " by executing /usr/bin/open", e); + int exitCode = SystemUtils.callExternalProcess(openCommands); + + // explorer.exe always return 1 + if (exitCode == 0 || (exitCode == 1 && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)) + return; + else + LOG.warning("Show " + path + " in explorer failed with code " + exitCode); + } catch (Throwable e) { + LOG.warning("Unable to show " + path + " in explorer", e); } - break; - default: - thread(() -> { - if (java.awt.Desktop.isDesktopSupported()) { - try { - java.awt.Desktop.getDesktop().open(file); - } catch (Throwable e) { - Logging.LOG.log(Level.SEVERE, "Unable to open " + path + " by java.awt.Desktop.getDesktop()::open", e); - } - } - }); + + // Fallback to open folder + openFolder(file.getParent()); + }); + } else { + // We do not have a universal method to show file in file manager. + openFolder(file.getParent()); } } + private static final String[] linuxBrowsers = { + "xdg-open", + "google-chrome", + "firefox", + "microsoft-edge", + "opera", + "konqueror", + "mozilla" + }; + /** - * Open URL by java.awt.Desktop + * Open URL in browser * * @param link null is allowed but will be ignored */ public static void openLink(String link) { if (link == null) return; + + String uri = NetworkUtils.encodeLocation(link); thread(() -> { - if (java.awt.Desktop.isDesktopSupported()) { - try { - java.awt.Desktop.getDesktop().browse(new URI(link)); - } catch (Throwable e) { - if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX) - try { - Runtime.getRuntime().exec(new String[]{"/usr/bin/open", link}); - } catch (IOException ex) { - Logging.LOG.log(Level.WARNING, "Unable to open link: " + link, ex); + try { + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { + Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", uri}); + return; + } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { + Runtime.getRuntime().exec(new String[]{"open", uri}); + return; + } else { + for (String browser : linuxBrowsers) { + Path path = SystemUtils.which(browser); + if (path != null) { + try { + Runtime.getRuntime().exec(new String[]{path.toString(), uri}); + return; + } catch (Throwable ignored) { + } } - Logging.LOG.log(Level.WARNING, "Failed to open link: " + link, e); + } + LOG.warning("No known browser found"); } + } catch (Throwable e) { + LOG.warning("Failed to open link: " + link + ", fallback to java.awt.Desktop", e); + } + + try { + java.awt.Desktop.getDesktop().browse(new URI(uri)); + } catch (Throwable e) { + LOG.warning("Failed to open link: " + link, e); } }); } - public static void bindInt(JFXTextField textField, Property property) { - textField.textProperty().bindBidirectional(property, SafeIntStringConverter.INSTANCE); + public static void bind(JFXTextField textField, Property property, StringConverter converter) { + TextFieldBinding binding = new TextFieldBinding<>(textField, property, converter); + binding.updateTextField(); + textField.getProperties().put("FXUtils.bind.binding", binding); + textField.focusedProperty().addListener(binding.focusedListener); + textField.sceneProperty().addListener(binding.sceneListener); + property.addListener(binding.propertyListener); } - public static void unbindInt(JFXTextField textField, Property property) { - textField.textProperty().unbindBidirectional(property); + public static void bindInt(JFXTextField textField, Property property) { + bind(textField, property, SafeStringConverter.fromInteger()); } public static void bindString(JFXTextField textField, Property property) { - textField.textProperty().bindBidirectional(property); + bind(textField, property, null); } - public static void unbindString(JFXTextField textField, Property property) { - textField.textProperty().unbindBidirectional(property); + public static void unbind(JFXTextField textField, Property property) { + TextFieldBinding binding = (TextFieldBinding) textField.getProperties().remove("FXUtils.bind.binding"); + if (binding != null) { + textField.focusedProperty().removeListener(binding.focusedListener); + textField.sceneProperty().removeListener(binding.sceneListener); + property.removeListener(binding.propertyListener); + } } - public static void bindBoolean(JFXToggleButton toggleButton, Property property) { - toggleButton.selectedProperty().bindBidirectional(property); - } + private static final class TextFieldBinding { + private final JFXTextField textField; + private final Property property; + private final StringConverter converter; + + public final ChangeListener focusedListener; + public final ChangeListener sceneListener; + public final InvalidationListener propertyListener; + + public TextFieldBinding(JFXTextField textField, Property property, StringConverter converter) { + this.textField = textField; + this.property = property; + this.converter = converter; + + focusedListener = (observable, oldFocused, newFocused) -> { + if (oldFocused && !newFocused) { + if (textField.validate()) { + updateProperty(); + } else { + // Rollback to old value + updateTextField(); + } + } + }; - public static void unbindBoolean(JFXToggleButton toggleButton, Property property) { - toggleButton.selectedProperty().unbindBidirectional(property); - } + sceneListener = (observable, oldScene, newScene) -> { + if (oldScene != null && newScene == null) { + // Component is being removed from scene + if (textField.validate()) { + updateProperty(); + } + } + }; + + propertyListener = observable -> { + updateTextField(); + }; + } + + public void updateProperty() { + String newText = textField.getText(); + @SuppressWarnings("unchecked") + T newValue = converter == null ? (T) newText : converter.fromString(newText); + + if (!Objects.equals(newValue, property.getValue())) { + property.setValue(newValue); + } + } - public static void bindBoolean(JFXCheckBox checkBox, Property property) { - checkBox.selectedProperty().bindBidirectional(property); + public void updateTextField() { + T value = property.getValue(); + textField.setText(converter == null ? (String) value : converter.toString(value)); + } } - public static void unbindBoolean(JFXCheckBox checkBox, Property property) { - checkBox.selectedProperty().unbindBidirectional(property); + private static final class EnumBidirectionalBinding> implements InvalidationListener, WeakListener { + private final WeakReference> comboBoxRef; + private final WeakReference> propertyRef; + private final int hashCode; + + private boolean updating = false; + + private EnumBidirectionalBinding(JFXComboBox comboBox, Property property) { + this.comboBoxRef = new WeakReference<>(comboBox); + this.propertyRef = new WeakReference<>(property); + this.hashCode = System.identityHashCode(comboBox) ^ System.identityHashCode(property); + } + + @Override + public void invalidated(Observable sourceProperty) { + if (!updating) { + final JFXComboBox comboBox = comboBoxRef.get(); + final Property property = propertyRef.get(); + + if (comboBox == null || property == null) { + if (comboBox != null) { + comboBox.getSelectionModel().selectedItemProperty().removeListener(this); + } + + if (property != null) { + property.removeListener(this); + } + } else { + updating = true; + try { + if (property == sourceProperty) { + E newValue = property.getValue(); + comboBox.getSelectionModel().select(newValue); + } else { + E newValue = comboBox.getSelectionModel().getSelectedItem(); + property.setValue(newValue); + } + } finally { + updating = false; + } + } + } + } + + @Override + public boolean wasGarbageCollected() { + return comboBoxRef.get() == null || propertyRef.get() == null; + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof EnumBidirectionalBinding)) + return false; + + EnumBidirectionalBinding that = (EnumBidirectionalBinding) o; + + final JFXComboBox comboBox = this.comboBoxRef.get(); + final Property property = this.propertyRef.get(); + + final JFXComboBox thatComboBox = that.comboBoxRef.get(); + final Property thatProperty = that.propertyRef.get(); + + if (comboBox == null || property == null || thatComboBox == null || thatProperty == null) + return false; + + return comboBox == thatComboBox && property == thatProperty; + } } /** * Bind combo box selection with given enum property bidirectionally. * You should only and always use {@code bindEnum} as well as {@code unbindEnum} at the same time. + * * @param comboBox the combo box being bound with {@code property}. * @param property the property being bound with {@code combo box}. - * @see #unbindEnum(JFXComboBox) - * @deprecated Use {@link ExtendedProperties#selectedItemPropertyFor(ComboBox)} + * @see #unbindEnum(JFXComboBox, Property) + * @see ExtendedProperties#selectedItemPropertyFor(ComboBox) */ - @SuppressWarnings("unchecked") - @Deprecated - public static void bindEnum(JFXComboBox comboBox, Property property) { - unbindEnum(comboBox); - ChangeListener listener = (a, b, newValue) -> - ((Property) property).setValue(property.getValue().getClass().getEnumConstants()[newValue.intValue()]); - comboBox.getSelectionModel().select(property.getValue().ordinal()); - comboBox.getProperties().put("FXUtils.bindEnum.listener", listener); - comboBox.getSelectionModel().selectedIndexProperty().addListener(listener); + public static > void bindEnum(JFXComboBox comboBox, Property property) { + EnumBidirectionalBinding binding = new EnumBidirectionalBinding<>(comboBox, property); + + comboBox.getSelectionModel().selectedItemProperty().removeListener(binding); + property.removeListener(binding); + + comboBox.getSelectionModel().select(property.getValue()); + comboBox.getSelectionModel().selectedItemProperty().addListener(binding); + property.addListener(binding); } /** * Unbind combo box selection with given enum property bidirectionally. * You should only and always use {@code bindEnum} as well as {@code unbindEnum} at the same time. + * * @param comboBox the combo box being bound with the property which can be inferred by {@code bindEnum}. * @see #bindEnum(JFXComboBox, Property) + * @see ExtendedProperties#selectedItemPropertyFor(ComboBox) */ - @SuppressWarnings("unchecked") - @Deprecated - public static void unbindEnum(JFXComboBox comboBox) { - ChangeListener listener = tryCast(comboBox.getProperties().get("FXUtils.bindEnum.listener"), ChangeListener.class).orElse(null); - if (listener == null) return; - comboBox.getSelectionModel().selectedIndexProperty().removeListener(listener); - } - - public static void smoothScrolling(ListView listView) { - listView.skinProperty().addListener(o -> { - ScrollBar bar = (ScrollBar) listView.lookup(".scroll-bar"); - Node virtualFlow = listView.lookup(".virtual-flow"); - double[] frictions = new double[]{0.99, 0.1, 0.05, 0.04, 0.03, 0.02, 0.01, 0.04, 0.01, 0.008, 0.008, 0.008, 0.008, 0.0006, 0.0005, 0.00003, 0.00001}; - double[] pushes = new double[]{1}; - double[] derivatives = new double[frictions.length]; - - Timeline timeline = new Timeline(); - bar.addEventHandler(MouseEvent.DRAG_DETECTED, e -> timeline.stop()); - - EventHandler scrollEventHandler = event -> { - if (event.getEventType() == ScrollEvent.SCROLL) { - int direction = event.getDeltaY() > 0 ? -1 : 1; - for (int i = 0; i < pushes.length; ++i) - derivatives[i] += direction * pushes[i]; - if (timeline.getStatus() == Animation.Status.STOPPED) - timeline.play(); - event.consume(); + public static > void unbindEnum(JFXComboBox comboBox, Property property) { + EnumBidirectionalBinding binding = new EnumBidirectionalBinding<>(comboBox, property); + comboBox.getSelectionModel().selectedItemProperty().removeListener(binding); + property.removeListener(binding); + } + + private static final class PaintBidirectionalBinding implements InvalidationListener, WeakListener { + private final WeakReference colorPickerRef; + private final WeakReference> propertyRef; + private final int hashCode; + + private boolean updating = false; + + private PaintBidirectionalBinding(ColorPicker colorPicker, Property property) { + this.colorPickerRef = new WeakReference<>(colorPicker); + this.propertyRef = new WeakReference<>(property); + this.hashCode = System.identityHashCode(colorPicker) ^ System.identityHashCode(property); + } + + @Override + public void invalidated(Observable sourceProperty) { + if (!updating) { + final ColorPicker colorPicker = colorPickerRef.get(); + final Property property = propertyRef.get(); + + if (colorPicker == null || property == null) { + if (colorPicker != null) { + colorPicker.valueProperty().removeListener(this); + } + + if (property != null) { + property.removeListener(this); + } + } else { + updating = true; + try { + if (property == sourceProperty) { + Paint newValue = property.getValue(); + if (newValue instanceof Color) + colorPicker.setValue((Color) newValue); + else + colorPicker.setValue(null); + } else { + Paint newValue = colorPicker.getValue(); + property.setValue(newValue); + } + } finally { + updating = false; + } } - }; + } + } - bar.addEventHandler(ScrollEvent.ANY, scrollEventHandler); - virtualFlow.setOnScroll(scrollEventHandler); - - timeline.getKeyFrames().add(new KeyFrame(Duration.millis(3), event -> { - for (int i = 0; i < derivatives.length; ++i) - derivatives[i] *= frictions[i]; - for (int i = 1; i < derivatives.length; ++i) - derivatives[i] += derivatives[i - 1]; - double dy = derivatives[derivatives.length - 1]; - double height = listView.getLayoutBounds().getHeight(); - bar.setValue(Math.min(Math.max(bar.getValue() + dy / height, 0), 1)); - if (Math.abs(dy) < 0.001) - timeline.stop(); - listView.requestLayout(); - })); - timeline.setCycleCount(Animation.INDEFINITE); - }); + @Override + public boolean wasGarbageCollected() { + return colorPickerRef.get() == null || propertyRef.get() == null; + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof FXUtils.PaintBidirectionalBinding)) + return false; + + var that = (FXUtils.PaintBidirectionalBinding) o; + + final ColorPicker colorPicker = this.colorPickerRef.get(); + final Property property = this.propertyRef.get(); + + final ColorPicker thatColorPicker = that.colorPickerRef.get(); + final Property thatProperty = that.propertyRef.get(); + + if (colorPicker == null || property == null || thatColorPicker == null || thatProperty == null) + return false; + + return colorPicker == thatColorPicker && property == thatProperty; + } } - public static T runInUIThread(Supplier supplier) { - if (javafx.application.Platform.isFxApplicationThread()) { - return supplier.get(); - } else { - CountDownLatch doneLatch = new CountDownLatch(1); - AtomicReference reference = new AtomicReference<>(); - Platform.runLater(() -> { + public static void bindPaint(ColorPicker colorPicker, Property property) { + PaintBidirectionalBinding binding = new PaintBidirectionalBinding(colorPicker, property); + + colorPicker.valueProperty().removeListener(binding); + property.removeListener(binding); + + if (property.getValue() instanceof Color) + colorPicker.setValue((Color) property.getValue()); + else + colorPicker.setValue(null); + + colorPicker.valueProperty().addListener(binding); + property.addListener(binding); + } + + private static final class WindowsSizeBidirectionalBinding implements InvalidationListener, WeakListener { + private final WeakReference> comboBoxRef; + private final WeakReference widthPropertyRef; + private final WeakReference heightPropertyRef; + + private final int hashCode; + + private boolean updating = false; + + private WindowsSizeBidirectionalBinding(JFXComboBox comboBox, + IntegerProperty widthProperty, + IntegerProperty heightProperty) { + this.comboBoxRef = new WeakReference<>(comboBox); + this.widthPropertyRef = new WeakReference<>(widthProperty); + this.heightPropertyRef = new WeakReference<>(heightProperty); + this.hashCode = System.identityHashCode(comboBox) + ^ System.identityHashCode(widthProperty) + ^ System.identityHashCode(heightProperty); + } + + @Override + public void invalidated(Observable observable) { + if (!updating) { + var comboBox = this.comboBoxRef.get(); + var widthProperty = this.widthPropertyRef.get(); + var heightProperty = this.heightPropertyRef.get(); + + if (comboBox == null || widthProperty == null || heightProperty == null) { + if (comboBox != null) { + comboBox.focusedProperty().removeListener(this); + comboBox.sceneProperty().removeListener(this); + } + if (widthProperty != null) + widthProperty.removeListener(this); + if (heightProperty != null) + heightProperty.removeListener(this); + } else { + updating = true; + try { + int width = widthProperty.get(); + int height = heightProperty.get(); + + if (observable instanceof ReadOnlyProperty + && ((ReadOnlyProperty) observable).getBean() == comboBox) { + String value = comboBox.valueProperty().get(); + if (value == null) + value = ""; + int idx = value.indexOf('x'); + if (idx < 0) + idx = value.indexOf('*'); + + if (idx < 0) { + LOG.warning("Bad window size: " + value); + comboBox.setValue(width + "x" + height); + return; + } + + String widthStr = value.substring(0, idx).trim(); + String heightStr = value.substring(idx + 1).trim(); + + int newWidth; + int newHeight; + try { + newWidth = Integer.parseInt(widthStr); + newHeight = Integer.parseInt(heightStr); + } catch (NumberFormatException e) { + LOG.warning("Bad window size: " + value); + comboBox.setValue(width + "x" + height); + return; + } + + widthProperty.set(newWidth); + heightProperty.set(newHeight); + } else { + comboBox.setValue(width + "x" + height); + } + } finally { + updating = false; + } + } + } + } + + @Override + public boolean wasGarbageCollected() { + return this.comboBoxRef.get() == null + || this.widthPropertyRef.get() == null + || this.heightPropertyRef.get() == null; + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (!(obj instanceof WindowsSizeBidirectionalBinding)) + return false; + + var that = (WindowsSizeBidirectionalBinding) obj; + + var comboBox = this.comboBoxRef.get(); + var widthProperty = this.widthPropertyRef.get(); + var heightProperty = this.heightPropertyRef.get(); + + var thatComboBox = that.comboBoxRef.get(); + var thatWidthProperty = that.widthPropertyRef.get(); + var thatHeightProperty = that.heightPropertyRef.get(); + + if (comboBox == null || widthProperty == null || heightProperty == null + || thatComboBox == null || thatWidthProperty == null || thatHeightProperty == null) { + return false; + } + + return comboBox == thatComboBox + && widthProperty == thatWidthProperty + && heightProperty == thatHeightProperty; + } + } + + public static void bindWindowsSize(JFXComboBox comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) { + comboBox.setValue(widthProperty.get() + "x" + heightProperty.get()); + var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty); + comboBox.focusedProperty().addListener(binding); + comboBox.sceneProperty().addListener(binding); + widthProperty.addListener(binding); + heightProperty.addListener(binding); + } + + public static void unbindWindowsSize(JFXComboBox comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) { + var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty); + comboBox.focusedProperty().removeListener(binding); + comboBox.sceneProperty().removeListener(binding); + widthProperty.removeListener(binding); + heightProperty.removeListener(binding); + } + + public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) { + int itemCount = children.length; + int childSelectedCount = 0; + for (BooleanProperty child : children) { + if (child.get()) + childSelectedCount++; + } + + allEnabled.set(childSelectedCount == itemCount); + + class Listener implements InvalidationListener { + private int childSelectedCount; + private boolean updating = false; + + public Listener(int childSelectedCount) { + this.childSelectedCount = childSelectedCount; + } + + @Override + public void invalidated(Observable observable) { + if (updating) + return; + + updating = true; try { - reference.set(supplier.get()); + boolean value = ((BooleanProperty) observable).get(); + + if (observable == allEnabled) { + for (BooleanProperty child : children) { + child.setValue(value); + } + childSelectedCount = value ? itemCount : 0; + } else { + if (value) + childSelectedCount++; + else + childSelectedCount--; + + allEnabled.set(childSelectedCount == itemCount); + } } finally { - doneLatch.countDown(); + updating = false; } + } + } - }); + InvalidationListener listener = new Listener(childSelectedCount); - try { - doneLatch.await(); - } catch (InterruptedException var3) { - Thread.currentThread().interrupt(); + WeakInvalidationListener weakListener = new WeakInvalidationListener(listener); + allEnabled.addListener(listener); + for (BooleanProperty child : children) { + child.addListener(weakListener); + } + } + + public static void setIcon(Stage stage) { + String icon; + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { + icon = "/assets/img/icon.png"; + } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { + icon = "/assets/img/icon-mac.png"; + } else { + icon = "/assets/img/icon@4x.png"; + } + stage.getIcons().add(newBuiltinImage(icon)); + } + + public static Image loadImage(Path path) throws Exception { + return loadImage(path, 0, 0, false, false); + } + + public static Image loadImage(Path path, + int requestedWidth, int requestedHeight, + boolean preserveRatio, boolean smooth) throws Exception { + try (var input = new BufferedInputStream(Files.newInputStream(path))) { + String ext = FileUtils.getExtension(path).toLowerCase(Locale.ROOT); + ImageLoader loader = ImageUtils.EXT_TO_LOADER.get(ext); + if (loader == null && !ImageUtils.DEFAULT_EXTS.contains(ext)) { + input.mark(ImageUtils.HEADER_BUFFER_SIZE); + byte[] headerBuffer = input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE); + input.reset(); + loader = ImageUtils.guessLoader(headerBuffer); } + if (loader == null) + loader = ImageUtils.DEFAULT; + return loader.load(input, requestedWidth, requestedHeight, preserveRatio, smooth); + } + } + + public static Image loadImage(String url) throws Exception { + URI uri = NetworkUtils.toURI(url); + + URLConnection connection = NetworkUtils.createConnection(uri); + if (connection instanceof HttpURLConnection) + connection = NetworkUtils.resolveConnection((HttpURLConnection) connection); + + try (BufferedInputStream input = new BufferedInputStream(connection.getInputStream())) { + String contentType = Objects.requireNonNull(connection.getContentType(), ""); + Matcher matcher = ImageUtils.CONTENT_TYPE_PATTERN.matcher(contentType); + if (matcher.find()) + contentType = matcher.group("type"); + + ImageLoader loader = ImageUtils.CONTENT_TYPE_TO_LOADER.get(contentType); + if (loader == null && !ImageUtils.DEFAULT_CONTENT_TYPES.contains(contentType)) { + input.mark(ImageUtils.HEADER_BUFFER_SIZE); + byte[] headerBuffer = input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE); + input.reset(); + loader = ImageUtils.guessLoader(headerBuffer); + } + + if (loader == null) + loader = ImageUtils.DEFAULT; + + return loader.load(input, 0, 0, false, false); + } + } + + /** + * Suppress IllegalArgumentException since the url is supposed to be correct definitely. + * + * @param url the url of image. The image resource should be a file within the jar. + * @return the image resource within the jar. + * @see org.jackhuang.hmcl.util.CrashReporter + * @see ResourceNotFoundError + */ + public static Image newBuiltinImage(String url) { + try { + return builtinImageCache.computeIfAbsent(url, Image::new); + } catch (IllegalArgumentException e) { + throw new ResourceNotFoundError("Cannot access image: " + url, e); + } + } + + /** + * Suppress IllegalArgumentException since the url is supposed to be correct definitely. + * + * @param url the url of image. The image resource should be a file within the jar. + * @param requestedWidth the image's bounding box width + * @param requestedHeight the image's bounding box height + * @param preserveRatio indicates whether to preserve the aspect ratio of + * the original image when scaling to fit the image within the + * specified bounding box + * @param smooth indicates whether to use a better quality filtering + * algorithm or a faster one when scaling this image to fit within + * the specified bounding box + * @return the image resource within the jar. + * @see org.jackhuang.hmcl.util.CrashReporter + * @see ResourceNotFoundError + */ + public static Image newBuiltinImage(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) { + try { + return new Image(url, requestedWidth, requestedHeight, preserveRatio, smooth); + } catch (IllegalArgumentException e) { + throw new ResourceNotFoundError("Cannot access image: " + url, e); + } + } + + public static Task getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) { + return new CacheFileTask(url) + .thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth)); + } + + public static ObservableValue newRemoteImage(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) { + var image = new SimpleObjectProperty(); + getRemoteImageTask(url, requestedWidth, requestedHeight, preserveRatio, smooth) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + image.set(result); + } else { + LOG.warning("An exception encountered while loading remote image: " + url, exception); + } + }) + .start(); + return image; + } + + public static JFXButton newRaisedButton(String text) { + JFXButton button = new JFXButton(text); + button.getStyleClass().add("jfx-button-raised"); + button.setButtonType(JFXButton.ButtonType.RAISED); + return button; + } + + public static JFXButton newBorderButton(String text) { + JFXButton button = new JFXButton(text); + button.getStyleClass().add("jfx-button-border"); + return button; + } - return reference.get(); + public static JFXButton newToggleButton4(SVG icon) { + JFXButton button = new JFXButton(); + button.getStyleClass().add("toggle-icon4"); + button.setGraphic(icon.createIcon(Theme.blackFill(), -1)); + return button; + } + + public static Label newSafeTruncatedLabel(String text) { + Label label = new Label(text); + label.setTextOverrun(OverrunStyle.CENTER_WORD_ELLIPSIS); + showTooltipWhenTruncated(label); + return label; + } + + private static final String LABEL_FULL_TEXT_PROP_KEY = FXUtils.class.getName() + ".LABEL_FULL_TEXT"; + + public static void showTooltipWhenTruncated(Labeled labeled) { + ReadOnlyBooleanProperty textTruncatedProperty = textTruncatedProperty(labeled); + if (textTruncatedProperty != null) { + ChangeListener listener = (observable, oldValue, newValue) -> { + var label = (Labeled) ((ReadOnlyProperty) observable).getBean(); + var tooltip = (Tooltip) label.getProperties().get(LABEL_FULL_TEXT_PROP_KEY); + + if (newValue) { + if (tooltip == null) { + tooltip = new Tooltip(); + tooltip.textProperty().bind(label.textProperty()); + label.getProperties().put(LABEL_FULL_TEXT_PROP_KEY, tooltip); + } + + FXUtils.installFastTooltip(label, tooltip); + } else if (tooltip != null) { + Tooltip.uninstall(label, tooltip); + } + }; + listener.changed(textTruncatedProperty, false, textTruncatedProperty.get()); + textTruncatedProperty.addListener(listener); } } - public static void applyDragListener(Node node, FileFilter filter, Consumer> callback) { + public static void applyDragListener(Node node, PathMatcher filter, Consumer> callback) { applyDragListener(node, filter, callback, null); } - public static void applyDragListener(Node node, FileFilter filter, Consumer> callback, Runnable dragDropped) { + public static void applyDragListener(Node node, PathMatcher filter, Consumer> callback, Runnable dragDropped) { node.setOnDragOver(event -> { if (event.getGestureSource() != node && event.getDragboard().hasFiles()) { - if (event.getDragboard().getFiles().stream().anyMatch(filter::accept)) + if (event.getDragboard().getFiles().stream().map(File::toPath).anyMatch(filter::matches)) event.acceptTransferModes(TransferMode.COPY_OR_MOVE); } event.consume(); @@ -477,7 +1274,7 @@ public static void applyDragListener(Node node, FileFilter filter, Consumer { List files = event.getDragboard().getFiles(); if (files != null) { - List acceptFiles = files.stream().filter(filter::accept).collect(Collectors.toList()); + List acceptFiles = files.stream().map(File::toPath).filter(filter::matches).toList(); if (!acceptFiles.isEmpty()) { callback.accept(acceptFiles); event.setDropCompleted(true); @@ -505,17 +1302,38 @@ public T fromString(String string) { } public static Callback, ListCell> jfxListCellFactory(Function graphicBuilder) { + Holder lastCell = new Holder<>(); return view -> new JFXListCell() { @Override public void updateItem(T item, boolean empty) { super.updateItem(item, empty); + + // https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html + if (this == lastCell.value && !isVisible()) + return; + lastCell.value = this; + if (!empty) { + setContentDisplay(ContentDisplay.GRAPHIC_ONLY); setGraphic(graphicBuilder.apply(item)); } } }; } + public static ColumnConstraints getColumnFillingWidth() { + ColumnConstraints constraint = new ColumnConstraints(); + constraint.setFillWidth(true); + return constraint; + } + + public static ColumnConstraints getColumnHgrowing() { + ColumnConstraints constraint = new ColumnConstraints(); + constraint.setFillWidth(true); + constraint.setHgrow(Priority.ALWAYS); + return constraint; + } + public static final Interpolator SINE = new Interpolator() { @Override protected double curve(double t) { @@ -528,10 +1346,198 @@ public String toString() { } }; - public static Runnable withJFXPopupClosing(Runnable runnable, JFXPopup popup) { - return () -> { - runnable.run(); - popup.hide(); - }; + public static final Interpolator EASE = Interpolator.SPLINE(0.25, 0.1, 0.25, 1); + + public static void onEscPressed(Node node, Runnable action) { + node.addEventHandler(KeyEvent.KEY_PRESSED, e -> { + if (e.getCode() == KeyCode.ESCAPE) { + action.run(); + e.consume(); + } + }); + } + + public static void onClicked(Node node, Runnable action) { + node.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { + if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 1) { + action.run(); + e.consume(); + } + }); + } + + public static void onScroll(Node node, List list, + ToIntFunction> finder, + Consumer updater + ) { + node.addEventHandler(ScrollEvent.SCROLL, event -> { + double deltaY = event.getDeltaY(); + if (deltaY == 0) + return; + + int index = finder.applyAsInt(list); + if (index < 0) return; + if (deltaY > 0) // up + index--; + else // down + index++; + + updater.accept(list.get((index + list.size()) % list.size())); + event.consume(); + }); + } + + public static void copyOnDoubleClick(Labeled label) { + label.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { + if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) { + String text = label.getText(); + if (text != null && !text.isEmpty()) { + copyText(label.getText()); + e.consume(); + } + } + }); + } + + public static void copyText(String text) { + ClipboardContent content = new ClipboardContent(); + content.putString(text); + Clipboard.getSystemClipboard().setContent(content); + + if (!Controllers.isStopped()) { + Controllers.showToast(i18n("message.copied")); + } + } + + public static List parseSegment(String segment, Consumer hyperlinkAction) { + if (segment.indexOf('<') < 0) + return Collections.singletonList(new Text(segment)); + + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(new InputSource(new StringReader("" + segment + ""))); + Element r = doc.getDocumentElement(); + + NodeList children = r.getChildNodes(); + List texts = new ArrayList<>(); + for (int i = 0; i < children.getLength(); i++) { + org.w3c.dom.Node node = children.item(i); + + if (node instanceof Element) { + Element element = (Element) node; + if ("a".equals(element.getTagName())) { + String href = element.getAttribute("href"); + Text text = new Text(element.getTextContent()); + onClicked(text, () -> { + String link = href; + try { + link = new URI(href).toASCIIString(); + } catch (URISyntaxException ignored) { + } + hyperlinkAction.accept(link); + }); + text.setCursor(Cursor.HAND); + text.setFill(Color.web("#0070E0")); + text.setUnderline(true); + texts.add(text); + } else if ("b".equals(element.getTagName())) { + Text text = new Text(element.getTextContent()); + text.getStyleClass().add("bold"); + texts.add(text); + } else if ("br".equals(element.getTagName())) { + texts.add(new Text("\n")); + } else { + throw new IllegalArgumentException("unsupported tag " + element.getTagName()); + } + } else { + texts.add(new Text(node.getTextContent())); + } + } + return texts; + } catch (SAXException | ParserConfigurationException | IOException e) { + LOG.warning("Failed to parse xml", e); + return Collections.singletonList(new Text(segment)); + } + } + + public static TextFlow segmentToTextFlow(final String segment, Consumer hyperlinkAction) { + TextFlow tf = new TextFlow(); + tf.getChildren().setAll(parseSegment(segment, hyperlinkAction)); + return tf; + } + + public static String toWeb(Color color) { + int r = (int) Math.round(color.getRed() * 255.0); + int g = (int) Math.round(color.getGreen() * 255.0); + int b = (int) Math.round(color.getBlue() * 255.0); + + return String.format("#%02x%02x%02x", r, g, b); + } + + public static FileChooser.ExtensionFilter getImageExtensionFilter() { + return new FileChooser.ExtensionFilter(i18n("extension.png"), + IMAGE_EXTENSIONS.stream().map(ext -> "*." + ext).toArray(String[]::new)); + } + + /** + * Intelligently determines the popup position to prevent the menu from exceeding screen boundaries. + * Supports multi-monitor setups by detecting the current screen where the component is located. + * Now handles first-time popup display by forcing layout measurement. + * + * @param root the root node to calculate position relative to + * @param popupInstance the popup instance to position + * @return the optimal vertical position for the popup menu + */ + public static JFXPopup.PopupVPosition determineOptimalPopupPosition(Node root, JFXPopup popupInstance) { + // Get the screen bounds in screen coordinates + Bounds screenBounds = root.localToScreen(root.getBoundsInLocal()); + + // Convert Bounds to Rectangle2D for getScreensForRectangle method + Rectangle2D boundsRect = new Rectangle2D( + screenBounds.getMinX(), screenBounds.getMinY(), + screenBounds.getWidth(), screenBounds.getHeight() + ); + + // Find the screen that contains this component (supports multi-monitor) + List screens = Screen.getScreensForRectangle(boundsRect); + Screen currentScreen = screens.isEmpty() ? Screen.getPrimary() : screens.get(0); + Rectangle2D visualBounds = currentScreen.getVisualBounds(); + + double screenHeight = visualBounds.getHeight(); + double screenMinY = visualBounds.getMinY(); + double itemScreenY = screenBounds.getMinY(); + + // Calculate available space relative to the current screen + double availableSpaceAbove = itemScreenY - screenMinY; + double availableSpaceBelow = screenMinY + screenHeight - itemScreenY - root.getBoundsInLocal().getHeight(); + + // Get popup content and ensure it's properly measured + Region popupContent = popupInstance.getPopupContent(); + + double menuHeight; + if (popupContent.getHeight() <= 0) { + // Force layout measurement if height is not yet available + popupContent.autosize(); + popupContent.applyCss(); + popupContent.layout(); + + // Get the measured height, or use a reasonable fallback + menuHeight = popupContent.getHeight(); + if (menuHeight <= 0) { + // Fallback: estimate based on number of menu items + // Each menu item is roughly 36px height + separators + padding + menuHeight = 300; // Conservative estimate for the current menu structure + } + } else { + menuHeight = popupContent.getHeight(); + } + + // Add some margin for safety + menuHeight += 20; + + return (availableSpaceAbove > menuHeight && availableSpaceBelow < menuHeight) + ? JFXPopup.PopupVPosition.BOTTOM // Show menu below the button, expanding downward + : JFXPopup.PopupVPosition.TOP; // Show menu above the button, expanding upward } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java new file mode 100644 index 0000000000..13506ce21a --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -0,0 +1,433 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2023 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui; + +import com.jfoenix.controls.JFXButton; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; +import javafx.stage.Stage; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.*; +import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.setting.StyleSheets; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.construct.TwoLineListItem; +import org.jackhuang.hmcl.util.*; +import org.jackhuang.hmcl.util.logging.Logger; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.platform.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import static org.jackhuang.hmcl.util.Pair.pair; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public class GameCrashWindow extends Stage { + private final Version version; + private final String memory; + private final String total_memory; + private final String java; + private final LibraryAnalyzer analyzer; + private final TextFlow reasonTextFlow = new TextFlow(new Text(i18n("game.crash.reason.unknown"))); + private final BooleanProperty loading = new SimpleBooleanProperty(); + private final TextFlow feedbackTextFlow = new TextFlow(); + + private final ManagedProcess managedProcess; + private final DefaultGameRepository repository; + private final ProcessListener.ExitType exitType; + private final LaunchOptions launchOptions; + private final View view; + + private final List logs; + + public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, Version version, LaunchOptions launchOptions, List logs) { + this.managedProcess = managedProcess; + this.exitType = exitType; + this.repository = repository; + this.version = version; + this.launchOptions = launchOptions; + this.logs = logs; + this.analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null)); + + memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " MB").orElse("-"); + + total_memory = MEGABYTES.formatBytes(SystemInfo.getTotalMemorySize()); + + this.java = launchOptions.getJava().getArchitecture() == Architecture.SYSTEM_ARCH + ? launchOptions.getJava().getVersion() + : launchOptions.getJava().getVersion() + " (" + launchOptions.getJava().getArchitecture().getDisplayName() + ")"; + + this.view = new View(); + + this.feedbackTextFlow.getChildren().addAll(FXUtils.parseSegment(i18n("game.crash.feedback"), Controllers::onHyperlinkAction)); + + setScene(new Scene(view, 800, 480)); + StyleSheets.init(getScene()); + setTitle(i18n("game.crash.title")); + FXUtils.setIcon(this); + + analyzeCrashReport(); + } + + @SuppressWarnings("unchecked") + private void analyzeCrashReport() { + loading.set(true); + Task.allOf(Task.supplyAsync(() -> { + String rawLog = logs.stream().map(Log::getLog).collect(Collectors.joining("\n")); + + // Get the crash-report from the crash-reports/xxx, or the output of console. + String crashReport = null; + try { + crashReport = CrashReportAnalyzer.findCrashReport(rawLog); + } catch (IOException e) { + LOG.warning("Failed to read crash report", e); + } + if (crashReport == null) { + crashReport = CrashReportAnalyzer.extractCrashReport(rawLog); + } + + return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>()); + }), Task.supplyAsync(() -> { + Path latestLog = repository.getRunDirectory(version.getId()).resolve("logs/latest.log"); + if (!Files.isReadable(latestLog)) { + return pair(new HashSet(), new HashSet()); + } + + String log; + try { + log = FileUtils.readTextMaybeNativeEncoding(latestLog); + } catch (IOException e) { + LOG.warning("Failed to read logs/latest.log", e); + return pair(new HashSet(), new HashSet()); + } + + return pair(CrashReportAnalyzer.analyze(log), CrashReportAnalyzer.findKeywordsFromCrashReport(log)); + })).whenComplete(Schedulers.javafx(), (taskResult, exception) -> { + loading.set(false); + + if (exception != null) { + LOG.warning("Failed to analyze crash report", exception); + reasonTextFlow.getChildren().setAll(FXUtils.parseSegment(i18n("game.crash.reason.unknown"), Controllers::onHyperlinkAction)); + } else { + EnumMap results = new EnumMap<>(CrashReportAnalyzer.Rule.class); + Set keywords = new HashSet<>(); + for (Pair, Set> pair : (List, Set>>) (List) taskResult) { + for (CrashReportAnalyzer.Result result : pair.getKey()) { + results.put(result.getRule(), result); + } + keywords.addAll(pair.getValue()); + } + + List segments = new ArrayList<>(FXUtils.parseSegment(i18n("game.crash.feedback"), Controllers::onHyperlinkAction)); + + LOG.info("Number of reasons: " + results.size()); + if (results.size() > 1) { + segments.add(new Text("\n")); + segments.addAll(FXUtils.parseSegment(i18n("game.crash.reason.multiple"), Controllers::onHyperlinkAction)); + } else { + segments.add(new Text("\n\n")); + } + + for (CrashReportAnalyzer.Result result : results.values()) { + String message; + switch (result.getRule()) { + case TOO_OLD_JAVA: + message = i18n("game.crash.reason.too_old_java", CrashReportAnalyzer.getJavaVersionFromMajorVersion(Integer.parseInt(result.getMatcher().group("expected")))); + break; + case MOD_RESOLUTION_CONFLICT: + case MOD_RESOLUTION_MISSING: + case MOD_RESOLUTION_COLLECTION: + message = i18n("game.crash.reason." + result.getRule().name().toLowerCase(Locale.ROOT), + translateFabricModId(result.getMatcher().group("sourcemod")), + parseFabricModId(result.getMatcher().group("destmod")), + parseFabricModId(result.getMatcher().group("destmod"))); + break; + case MOD_RESOLUTION_MISSING_MINECRAFT: + message = i18n("game.crash.reason." + result.getRule().name().toLowerCase(Locale.ROOT), + translateFabricModId(result.getMatcher().group("mod")), + result.getMatcher().group("version")); + break; + case MOD_FOREST_OPTIFINE: + case TWILIGHT_FOREST_OPTIFINE: + case PERFORMANT_FOREST_OPTIFINE: + case JADE_FOREST_OPTIFINE: + case NEOFORGE_FOREST_OPTIFINE: + message = i18n("game.crash.reason.mod", "OptiFine"); + LOG.info("Crash cause: " + result.getRule() + ": " + i18n("game.crash.reason.mod", "OptiFine")); + break; + default: + message = i18n("game.crash.reason." + result.getRule().name().toLowerCase(Locale.ROOT), + Arrays.stream(result.getRule().getGroupNames()).map(groupName -> result.getMatcher().group(groupName)) + .toArray()); + break; + } + LOG.info("Crash cause: " + result.getRule() + ": " + message); + segments.addAll(FXUtils.parseSegment(message, Controllers::onHyperlinkAction)); + segments.add(new Text("\n\n")); + } + if (results.isEmpty()) { + if (!keywords.isEmpty()) { + reasonTextFlow.getChildren().setAll(new Text(i18n("game.crash.reason.stacktrace", String.join(", ", keywords)))); + LOG.info("Crash reason unknown, but some log keywords have been found: " + String.join(", ", keywords)); + } else { + reasonTextFlow.getChildren().setAll(FXUtils.parseSegment(i18n("game.crash.reason.unknown"), Controllers::onHyperlinkAction)); + LOG.info("Crash reason unknown"); + } + } else { + feedbackTextFlow.setVisible(false); + reasonTextFlow.getChildren().setAll(segments); + } + } + }).start(); + } + + private static final Pattern FABRIC_MOD_ID = Pattern.compile("\\{(?.*?) @ (?.*?)}"); + + private String translateFabricModId(String modName) { + switch (modName) { + case "fabricloader": + return "Fabric"; + case "fabric": + return "Fabric API"; + case "minecraft": + return "Minecraft"; + default: + return modName; + } + } + + private String parseFabricModId(String modName) { + Matcher matcher = FABRIC_MOD_ID.matcher(modName); + if (matcher.find()) { + String modid = matcher.group("modid"); + String version = matcher.group("version"); + if ("[*]".equals(version)) { + return i18n("game.crash.reason.mod_resolution_mod_version.any", translateFabricModId(modid)); + } else { + return i18n("game.crash.reason.mod_resolution_mod_version", translateFabricModId(modid), version); + } + } + return translateFabricModId(modName); + } + + private void showLogWindow() { + LogWindow logWindow = new LogWindow(managedProcess); + + logWindow.logLine(new Log(Logger.filterForbiddenToken("Command: " + new CommandBuilder().addAll(managedProcess.getCommands())), Log4jLevel.INFO)); + if (managedProcess.getClasspath() != null) + logWindow.logLine(new Log("ClassPath: " + managedProcess.getClasspath(), Log4jLevel.INFO)); + logWindow.logLines(logs); + logWindow.show(); + } + + private void exportGameCrashInfo() { + Path logFile = Paths.get("minecraft-exported-crash-info-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".zip").toAbsolutePath(); + + CompletableFuture.supplyAsync(() -> + logs.stream().map(Log::getLog).collect(Collectors.joining("\n"))) + .thenComposeAsync(logs -> + LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString())) + .handleAsync((result, exception) -> { + Alert alert; + + if (exception == null) { + FXUtils.showFileInExplorer(logFile); + alert = new Alert(Alert.AlertType.INFORMATION, i18n("settings.launcher.launcher_log.export.success", logFile)); + } else { + LOG.warning("Failed to export game crash info", exception); + alert = new Alert(Alert.AlertType.WARNING, i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception)); + } + + alert.setTitle(i18n("settings.launcher.launcher_log.export")); + alert.showAndWait(); + + return null; + }); + } + + private final class View extends VBox { + + View() { + setStyle("-fx-background-color: white"); + + HBox titlePane = new HBox(); + { + Label title = new Label(); + HBox.setHgrow(title, Priority.ALWAYS); + + switch (exitType) { + case JVM_ERROR: + title.setText(i18n("launch.failed.cannot_create_jvm")); + break; + case APPLICATION_ERROR: + title.setText(i18n("launch.failed.exited_abnormally")); + break; + case SIGKILL: + title.setText(i18n("launch.failed.sigkill")); + break; + } + + titlePane.setAlignment(Pos.CENTER); + titlePane.getStyleClass().addAll("jfx-tool-bar-second", "depth-1", "padding-8"); + titlePane.getChildren().setAll(title); + } + + HBox infoPane = new HBox(8); + { + infoPane.setPadding(new Insets(8)); + infoPane.setAlignment(Pos.CENTER_LEFT); + + TwoLineListItem launcher = new TwoLineListItem(); + launcher.getStyleClass().setAll("two-line-item-second-large"); + launcher.setTitle(i18n("launcher")); + launcher.setSubtitle(Metadata.VERSION); + + TwoLineListItem version = new TwoLineListItem(); + version.getStyleClass().setAll("two-line-item-second-large"); + version.setTitle(i18n("game.version")); + version.setSubtitle(GameCrashWindow.this.version.getId()); + + TwoLineListItem total_memory = new TwoLineListItem(); + total_memory.getStyleClass().setAll("two-line-item-second-large"); + total_memory.setTitle(i18n("settings.physical_memory")); + total_memory.setSubtitle(GameCrashWindow.this.total_memory); + + TwoLineListItem memory = new TwoLineListItem(); + memory.getStyleClass().setAll("two-line-item-second-large"); + memory.setTitle(i18n("settings.memory")); + memory.setSubtitle(GameCrashWindow.this.memory); + + TwoLineListItem java = new TwoLineListItem(); + java.getStyleClass().setAll("two-line-item-second-large"); + java.setTitle("Java"); + java.setSubtitle(GameCrashWindow.this.java); + + TwoLineListItem os = new TwoLineListItem(); + os.getStyleClass().setAll("two-line-item-second-large"); + os.setTitle(i18n("system.operating_system")); + os.setSubtitle(Lang.requireNonNullElse(OperatingSystem.OS_RELEASE_NAME, OperatingSystem.SYSTEM_NAME)); + + TwoLineListItem arch = new TwoLineListItem(); + arch.getStyleClass().setAll("two-line-item-second-large"); + arch.setTitle(i18n("system.architecture")); + arch.setSubtitle(Architecture.SYSTEM_ARCH.getDisplayName()); + + infoPane.getChildren().setAll(launcher, version, total_memory, memory, java, os, arch); + } + + HBox moddedPane = new HBox(8); + { + moddedPane.setPadding(new Insets(8)); + moddedPane.setAlignment(Pos.CENTER_LEFT); + + for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) { + if (!type.getPatchId().isEmpty()) { + analyzer.getVersion(type).ifPresent(ver -> { + TwoLineListItem item = new TwoLineListItem(); + item.getStyleClass().setAll("two-line-item-second-large"); + item.setTitle(i18n("install.installer." + type.getPatchId())); + item.setSubtitle(ver); + moddedPane.getChildren().add(item); + }); + } + } + } + + VBox gameDirPane = new VBox(8); + { + TwoLineListItem gameDir = new TwoLineListItem(); + gameDir.getStyleClass().setAll("two-line-item-second-large"); + gameDir.setTitle(i18n("game.directory")); + gameDir.setSubtitle(launchOptions.getGameDir().toAbsolutePath().toString()); + FXUtils.installFastTooltip(gameDir, i18n("game.directory")); + + TwoLineListItem javaDir = new TwoLineListItem(); + javaDir.getStyleClass().setAll("two-line-item-second-large"); + javaDir.setTitle(i18n("settings.game.java_directory")); + javaDir.setSubtitle(launchOptions.getJava().getBinary().toAbsolutePath().toString()); + FXUtils.installFastTooltip(javaDir, i18n("settings.game.java_directory")); + + Label reasonTitle = new Label(i18n("game.crash.reason")); + reasonTitle.getStyleClass().add("two-line-item-second-large-title"); + + ScrollPane reasonPane = new ScrollPane(reasonTextFlow); + reasonPane.setFitToWidth(true); + reasonPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + reasonPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + + gameDirPane.setPadding(new Insets(8)); + VBox.setVgrow(gameDirPane, Priority.ALWAYS); + FXUtils.onChangeAndOperate(feedbackTextFlow.visibleProperty(), visible -> { + if (visible) { + gameDirPane.getChildren().setAll(gameDir, javaDir, new VBox(reasonTitle, reasonPane, feedbackTextFlow)); + } else { + gameDirPane.getChildren().setAll(gameDir, javaDir, new VBox(reasonTitle, reasonPane)); + } + }); + } + + HBox toolBar = new HBox(); + { + JFXButton exportGameCrashInfoButton = FXUtils.newRaisedButton(i18n("logwindow.export_game_crash_logs")); + exportGameCrashInfoButton.setOnAction(e -> exportGameCrashInfo()); + + JFXButton logButton = FXUtils.newRaisedButton(i18n("logwindow.title")); + logButton.setOnAction(e -> showLogWindow()); + + JFXButton helpButton = FXUtils.newRaisedButton(i18n("help")); + helpButton.setOnAction(e -> FXUtils.openLink(Metadata.CONTACT_URL)); + FXUtils.installFastTooltip(helpButton, i18n("logwindow.help")); + + + toolBar.setPadding(new Insets(8)); + toolBar.setSpacing(8); + toolBar.getStyleClass().add("jfx-tool-bar"); + toolBar.getChildren().setAll(exportGameCrashInfoButton, logButton, helpButton); + } + + getChildren().setAll(titlePane, infoPane, moddedPane, gameDirPane, toolBar); + } + + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java new file mode 100644 index 0000000000..a7dcc2643d --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java @@ -0,0 +1,315 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui; + +import javafx.scene.Cursor; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; +import org.jackhuang.hmcl.util.StringUtils; +import org.jsoup.nodes.Node; +import org.jsoup.nodes.TextNode; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/** + * @author Glavo + */ +public final class HTMLRenderer { + private static URI resolveLink(Node linkNode) { + String href = linkNode.absUrl("href"); + if (href.isEmpty()) + return null; + + try { + return new URI(href); + } catch (Throwable e) { + return null; + } + } + + private final List children = new ArrayList<>(); + private final List stack = new ArrayList<>(); + + private boolean bold; + private boolean italic; + private boolean underline; + private boolean strike; + private boolean highlight; + private String headerLevel; + private Node hyperlink; + + private final Consumer onClickHyperlink; + + public HTMLRenderer(Consumer onClickHyperlink) { + this.onClickHyperlink = onClickHyperlink; + } + + private void updateStyle() { + bold = false; + italic = false; + underline = false; + strike = false; + highlight = false; + headerLevel = null; + hyperlink = null; + + for (Node node : stack) { + String nodeName = node.nodeName(); + switch (nodeName) { + case "b": + case "strong": + bold = true; + break; + case "i": + case "em": + italic = true; + break; + case "ins": + underline = true; + break; + case "del": + strike = true; + break; + case "mark": + highlight = true; + break; + case "a": + hyperlink = node; + break; + case "h1": + case "h2": + case "h3": + case "h4": + case "h5": + case "h6": + headerLevel = nodeName; + break; + } + } + } + + private void pushNode(Node node) { + stack.add(node); + updateStyle(); + } + + private void popNode() { + stack.remove(stack.size() - 1); + updateStyle(); + } + + private void applyStyle(Text text) { + if (hyperlink != null) { + URI target = resolveLink(hyperlink); + if (target != null) { + FXUtils.onClicked(text, () -> onClickHyperlink.accept(target)); + text.setCursor(Cursor.HAND); + } + text.getStyleClass().add("html-hyperlink"); + } + + if (hyperlink != null || underline) + text.setUnderline(true); + + if (strike) + text.setStrikethrough(true); + + if (bold || highlight) + text.getStyleClass().add("html-bold"); + + if (italic) + text.getStyleClass().add("html-italic"); + + if (headerLevel != null) + text.getStyleClass().add("html-" + headerLevel); + } + + private void appendText(String text) { + Text textNode = new Text(text); + applyStyle(textNode); + children.add(textNode); + } + + private void appendAutoLineBreak(String text) { + AutoLineBreak textNode = new AutoLineBreak(text); + applyStyle(textNode); + children.add(textNode); + } + + private void appendImage(Node node) { + String src = node.absUrl("src"); + String alt = node.attr("alt"); + + if (StringUtils.isNotBlank(src)) { + String widthAttr = node.attr("width"); + String heightAttr = node.attr("height"); + + int width = 0; + int height = 0; + + if (!widthAttr.isEmpty() && !heightAttr.isEmpty()) { + try { + width = (int) Double.parseDouble(widthAttr); + height = (int) Double.parseDouble(heightAttr); + } catch (NumberFormatException ignored) { + } + + if (width <= 0 || height <= 0) { + width = 0; + height = 0; + } + } + + try { + Image image = FXUtils.getRemoteImageTask(src, width, height, true, true) + .run(); + if (image == null) + throw new AssertionError("Image loading task returned null"); + + ImageView imageView = new ImageView(image); + if (hyperlink != null) { + URI target = resolveLink(hyperlink); + if (target != null) { + FXUtils.onClicked(imageView, () -> onClickHyperlink.accept(target)); + imageView.setCursor(Cursor.HAND); + } + } + children.add(imageView); + return; + } catch (Throwable e) { + LOG.warning("Failed to load image: " + src, e); + } + } + + if (!alt.isEmpty()) + appendText(alt); + } + + public void appendNode(Node node) { + if (node instanceof TextNode) { + appendText(((TextNode) node).text()); + } + + String name = node.nodeName(); + switch (name) { + case "img": + appendImage(node); + break; + case "li": + appendText("\n \u2022 "); + break; + case "dt": + appendText(" "); + break; + case "p": + case "h1": + case "h2": + case "h3": + case "h4": + case "h5": + case "h6": + case "tr": + if (!children.isEmpty()) + appendAutoLineBreak("\n\n"); + break; + } + + if (node.childNodeSize() > 0) { + pushNode(node); + for (Node childNode : node.childNodes()) { + appendNode(childNode); + } + popNode(); + } + + switch (name) { + case "br": + case "dd": + case "p": + case "h1": + case "h2": + case "h3": + case "h4": + case "h5": + case "h6": + appendAutoLineBreak("\n"); + break; + } + } + + private static boolean isSpacing(String text) { + if (text == null) + return true; + + for (int i = 0; i < text.length(); i++) { + char ch = text.charAt(i); + if (ch != ' ' && ch != '\t') + return false; + } + return true; + } + + public void mergeLineBreaks() { + for (int i = 0; i < this.children.size(); i++) { + javafx.scene.Node child = this.children.get(i); + if (child instanceof AutoLineBreak) { + int lastAutoLineBreak = -1; + + for (int j = i + 1; j < this.children.size(); j++) { + javafx.scene.Node otherChild = this.children.get(j); + + if (otherChild instanceof AutoLineBreak) { + lastAutoLineBreak = j; + } else if (otherChild instanceof Text && isSpacing(((Text) otherChild).getText())) { + // do nothing + } else { + break; + } + } + + if (lastAutoLineBreak > 0) { + this.children.subList(i + 1, lastAutoLineBreak + 1).clear(); + + if (((Text) child).getText().length() == 1) { + ((Text) child).setText("\n\n"); + } + } + } + } + } + + public TextFlow render() { + TextFlow textFlow = new TextFlow(); + textFlow.getStyleClass().add("html"); + textFlow.getChildren().setAll(children); + return textFlow; + } + + private static final class AutoLineBreak extends Text { + public AutoLineBreak(String text) { + super(text); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java index d584789c1a..1925bbeb71 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,39 +17,449 @@ */ package org.jackhuang.hmcl.ui; -import com.jfoenix.effects.JFXDepthManager; -import javafx.fxml.FXML; +import com.jfoenix.controls.JFXButton; +import javafx.beans.Observable; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.value.ChangeListener; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Control; import javafx.scene.control.Label; -import javafx.scene.layout.BorderPane; +import javafx.scene.control.Skin; +import javafx.scene.control.SkinBase; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseButton; +import javafx.scene.layout.*; +import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.setting.VersionIconType; +import org.jackhuang.hmcl.ui.construct.RipplerContainer; +import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; -import java.util.function.Consumer; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; /** * @author huangyuhui */ -public class InstallerItem extends BorderPane { - private final Consumer deleteCallback; +public class InstallerItem extends Control { + private final String id; + private final VersionIconType iconType; + private final Style style; + private final ObjectProperty versionProperty = new SimpleObjectProperty<>(this, "version", null); + private final ObjectProperty resolvedStateProperty = new SimpleObjectProperty<>(this, "resolvedState", InstallableState.INSTANCE); - @FXML - private Label lblInstallerArtifact; + private final ObjectProperty onInstall = new SimpleObjectProperty<>(this, "onInstall"); + private final ObjectProperty onRemove = new SimpleObjectProperty<>(this, "onRemove"); - @FXML - private Label lblInstallerVersion; + public interface State { + } + + public static final class InstallableState implements State { + public static final InstallableState INSTANCE = new InstallableState(); + + private InstallableState() { + } + } + + public static final class IncompatibleState implements State { + private final String incompatibleItemName; + private final String incompatibleItemVersion; + + public IncompatibleState(String incompatibleItemName, String incompatibleItemVersion) { + this.incompatibleItemName = incompatibleItemName; + this.incompatibleItemVersion = incompatibleItemVersion; + } + + public String getIncompatibleItemName() { + return incompatibleItemName; + } + + public String getIncompatibleItemVersion() { + return incompatibleItemVersion; + } + } + + public static final class InstalledState implements State { + private final String version; + private final boolean external; + private final boolean incompatibleWithGame; + + public InstalledState(String version, boolean external, boolean incompatibleWithGame) { + this.version = version; + this.external = external; + this.incompatibleWithGame = incompatibleWithGame; + } + + public String getVersion() { + return version; + } + + public boolean isExternal() { + return external; + } + + public boolean isIncompatibleWithGame() { + return incompatibleWithGame; + } + } + + public enum Style { + LIST_ITEM, + CARD, + } + + public InstallerItem(LibraryAnalyzer.LibraryType id, Style style) { + this(id.getPatchId(), style); + } + + public InstallerItem(String id, Style style) { + this.id = id; + this.style = style; + + switch (id) { + case "game": + iconType = VersionIconType.GRASS; + break; + case "fabric": + case "fabric-api": + iconType = VersionIconType.FABRIC; + break; + case "forge": + iconType = VersionIconType.FORGE; + break; + case "cleanroom": + iconType = VersionIconType.CLEANROOM; + break; + case "liteloader": + iconType = VersionIconType.CHICKEN; + break; + case "optifine": + iconType = VersionIconType.OPTIFINE; + break; + case "quilt": + case "quilt-api": + iconType = VersionIconType.QUILT; + break; + case "neoforge": + iconType = VersionIconType.NEO_FORGE; + break; + default: + iconType = null; + break; + } + } + + public String getLibraryId() { + return id; + } + + public ObjectProperty versionProperty() { + return versionProperty; + } + + public ObjectProperty resolvedStateProperty() { + return resolvedStateProperty; + } + + public ObjectProperty onInstallProperty() { + return onInstall; + } + + public Runnable getOnInstall() { + return onInstall.get(); + } + + public void setOnInstall(Runnable onInstall) { + this.onInstall.set(onInstall); + } + + public ObjectProperty onRemoveProperty() { + return onRemove; + } + + public Runnable getOnRemove() { + return onRemove.get(); + } - public InstallerItem(String artifact, String version, Consumer deleteCallback) { - this.deleteCallback = deleteCallback; - FXUtils.loadFXML(this, "/assets/fxml/version/installer-item.fxml"); + public void setOnRemove(Runnable onRemove) { + this.onRemove.set(onRemove); + } - setStyle("-fx-background-radius: 2; -fx-background-color: white; -fx-padding: 8;"); - JFXDepthManager.setDepth(this, 1); - lblInstallerArtifact.setText(artifact); - lblInstallerVersion.setText(i18n("archive.version") + ": " + version); + @Override + protected Skin createDefaultSkin() { + return new InstallerItemSkin(this); } - @FXML - private void onDelete() { - deleteCallback.accept(this); + public final static class InstallerItemGroup { + private final InstallerItem game; + + private final InstallerItem[] libraries; + + private Set getIncompatibles(Map> incompatibleMap, InstallerItem item) { + return incompatibleMap.computeIfAbsent(item, it -> new HashSet<>()); + } + + private void addIncompatibles(Map> incompatibleMap, InstallerItem item, InstallerItem... others) { + Set set = getIncompatibles(incompatibleMap, item); + for (InstallerItem other : others) { + set.add(other); + getIncompatibles(incompatibleMap, other).add(item); + } + } + + private void mutualIncompatible(Map> incompatibleMap, InstallerItem... items) { + for (InstallerItem item : items) { + Set set = getIncompatibles(incompatibleMap, item); + + for (InstallerItem item2 : items) { + if (item2 != item) { + set.add(item2); + } + } + } + } + + public InstallerItemGroup(String gameVersion, Style style) { + game = new InstallerItem(MINECRAFT, style); + InstallerItem fabric = new InstallerItem(FABRIC, style); + InstallerItem fabricApi = new InstallerItem(FABRIC_API, style); + InstallerItem forge = new InstallerItem(FORGE, style); + InstallerItem cleanroom = new InstallerItem(CLEANROOM, style); + InstallerItem neoForge = new InstallerItem(NEO_FORGE, style); + InstallerItem liteLoader = new InstallerItem(LITELOADER, style); + InstallerItem optiFine = new InstallerItem(OPTIFINE, style); + InstallerItem quilt = new InstallerItem(QUILT, style); + InstallerItem quiltApi = new InstallerItem(QUILT_API, style); + + Map> incompatibleMap = new HashMap<>(); + mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom); + addIncompatibles(incompatibleMap, liteLoader, fabric, quilt, neoForge, cleanroom); + addIncompatibles(incompatibleMap, optiFine, fabric, quilt, neoForge, cleanroom); + addIncompatibles(incompatibleMap, fabricApi, forge, quiltApi, neoForge, liteLoader, optiFine, cleanroom); + addIncompatibles(incompatibleMap, quiltApi, forge, fabric, fabricApi, neoForge, liteLoader, optiFine, cleanroom); + + for (Map.Entry> entry : incompatibleMap.entrySet()) { + InstallerItem item = entry.getKey(); + Set incompatibleItems = entry.getValue(); + + Observable[] bindings = new Observable[incompatibleItems.size() + 1]; + bindings[0] = item.versionProperty; + int i = 1; + for (InstallerItem other : incompatibleItems) { + bindings[i++] = other.versionProperty; + } + + item.resolvedStateProperty.bind(Bindings.createObjectBinding(() -> { + InstalledState itemVersion = item.versionProperty.get(); + if (itemVersion != null) { + return itemVersion; + } + + for (InstallerItem other : incompatibleItems) { + InstalledState otherVersion = other.versionProperty.get(); + if (otherVersion != null) { + return new IncompatibleState(other.id, otherVersion.version); + } + } + + return InstallableState.INSTANCE; + }, bindings)); + } + + if (gameVersion != null) { + game.versionProperty.set(new InstalledState(gameVersion, false, false)); + } + + InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, cleanroom}; + + for (InstallerItem item : all) { + if (!item.resolvedStateProperty.isBound()) { + item.resolvedStateProperty.bind(Bindings.createObjectBinding(() -> { + InstalledState itemVersion = item.versionProperty.get(); + if (itemVersion != null) { + return itemVersion; + } + return InstallableState.INSTANCE; + }, item.versionProperty)); + } + } + + if (gameVersion == null) { + this.libraries = all; + } else if (gameVersion.equals("1.12.2")) { + this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, optiFine}; + } else if (GameVersionNumber.compare(gameVersion, "1.13") < 0) { + this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine}; + } else { + this.libraries = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi}; + } + } + + public InstallerItem getGame() { + return game; + } + + public InstallerItem[] getLibraries() { + return libraries; + } + } + + private static final class InstallerItemSkin extends SkinBase { + private static final PseudoClass LIST_ITEM = PseudoClass.getPseudoClass("list-item"); + private static final PseudoClass CARD = PseudoClass.getPseudoClass("card"); + + @SuppressWarnings({"FieldCanBeLocal", "unused"}) + private final ChangeListener holder; + + InstallerItemSkin(InstallerItem control) { + super(control); + + Pane pane; + if (control.style == Style.CARD) { + pane = new VBox(); + holder = FXUtils.onWeakChangeAndOperate(pane.widthProperty(), v -> FXUtils.setLimitHeight(pane, v.doubleValue() * 0.7)); + } else { + pane = new HBox(); + holder = null; + } + pane.getStyleClass().add("installer-item"); + RipplerContainer container = new RipplerContainer(pane); + getChildren().setAll(container); + + pane.pseudoClassStateChanged(LIST_ITEM, control.style == Style.LIST_ITEM); + pane.pseudoClassStateChanged(CARD, control.style == Style.CARD); + + if (control.iconType != null) { + ImageView view = new ImageView(control.iconType.getIcon()); + Node node = FXUtils.limitingSize(view, 32, 32); + node.setMouseTransparent(true); + node.getStyleClass().add("installer-item-image"); + pane.getChildren().add(node); + + if (control.style == Style.CARD) { + VBox.setMargin(node, new Insets(8, 0, 16, 0)); + } + } + + Label nameLabel = new Label(); + nameLabel.getStyleClass().add("installer-item-name"); + nameLabel.setMouseTransparent(true); + pane.getChildren().add(nameLabel); + nameLabel.textProperty().set(I18n.hasKey("install.installer." + control.id) ? i18n("install.installer." + control.id) : control.id); + HBox.setMargin(nameLabel, new Insets(0, 4, 0, 4)); + + Label statusLabel = new Label(); + statusLabel.getStyleClass().add("installer-item-status"); + statusLabel.setMouseTransparent(true); + pane.getChildren().add(statusLabel); + HBox.setHgrow(statusLabel, Priority.ALWAYS); + statusLabel.textProperty().bind(Bindings.createStringBinding(() -> { + State state = control.resolvedStateProperty.get(); + + if (state instanceof InstalledState) { + InstalledState s = (InstalledState) state; + if (s.incompatibleWithGame) { + return i18n("install.installer.change_version", s.version); + } + if (s.external) { + return i18n("install.installer.external_version", s.version); + } + return i18n("install.installer.version", s.version); + } else if (state instanceof InstallableState) { + return control.style == Style.CARD + ? i18n("install.installer.do_not_install") + : i18n("install.installer.not_installed"); + } else if (state instanceof IncompatibleState) { + return i18n("install.installer.incompatible", i18n("install.installer." + ((IncompatibleState) state).incompatibleItemName)); + } else { + throw new AssertionError("Unknown state type: " + state.getClass()); + } + }, control.resolvedStateProperty)); + BorderPane.setMargin(statusLabel, new Insets(0, 0, 0, 8)); + BorderPane.setAlignment(statusLabel, Pos.CENTER_LEFT); + + HBox buttonsContainer = new HBox(); + buttonsContainer.setSpacing(8); + buttonsContainer.setAlignment(Pos.CENTER); + pane.getChildren().add(buttonsContainer); + + JFXButton removeButton = new JFXButton(); + removeButton.setGraphic(SVG.CLOSE.createIcon(Theme.blackFill(), -1)); + removeButton.getStyleClass().add("toggle-icon4"); + if (control.id.equals(MINECRAFT.getPatchId())) { + removeButton.setVisible(false); + } else { + removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> { + State state = control.resolvedStateProperty.get(); + return state instanceof InstalledState && !((InstalledState) state).external; + }, control.resolvedStateProperty)); + } + removeButton.managedProperty().bind(removeButton.visibleProperty()); + removeButton.setOnAction(e -> { + Runnable onRemove = control.getOnRemove(); + if (onRemove != null) + onRemove.run(); + }); + buttonsContainer.getChildren().add(removeButton); + + JFXButton installButton = new JFXButton(); + installButton.graphicProperty().bind(Bindings.createObjectBinding(() -> + control.resolvedStateProperty.get() instanceof InstallableState ? + SVG.ARROW_FORWARD.createIcon(Theme.blackFill(), -1) : + SVG.UPDATE.createIcon(Theme.blackFill(), -1), + control.resolvedStateProperty + )); + installButton.getStyleClass().add("toggle-icon4"); + installButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> { + if (control.getOnInstall() == null) { + return false; + } + + State state = control.resolvedStateProperty.get(); + if (state instanceof InstallableState) { + return true; + } + if (state instanceof InstalledState) { + return !((InstalledState) state).external; + } + + return false; + }, control.resolvedStateProperty, control.onInstall)); + installButton.managedProperty().bind(installButton.visibleProperty()); + installButton.setOnAction(e -> { + Runnable onInstall = control.getOnInstall(); + if (onInstall != null) + onInstall.run(); + }); + buttonsContainer.getChildren().add(installButton); + + FXUtils.onChangeAndOperate(installButton.visibleProperty(), clickable -> { + if (clickable) { + container.setOnMouseClicked(event -> { + Runnable onInstall = control.getOnInstall(); + if (onInstall != null && event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 1) { + onInstall.run(); + event.consume(); + } + }); + pane.setCursor(Cursor.HAND); + } else { + container.setOnMouseClicked(null); + pane.setCursor(Cursor.DEFAULT); + } + }); + } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LeftPaneController.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LeftPaneController.java deleted file mode 100644 index 991f4fbc54..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LeftPaneController.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui; - -import com.jfoenix.concurrency.JFXUtilities; -import javafx.application.Platform; -import javafx.scene.image.Image; -import javafx.scene.layout.Region; -import org.jackhuang.hmcl.event.EventBus; -import org.jackhuang.hmcl.event.RefreshedVersionsEvent; -import org.jackhuang.hmcl.game.HMCLGameRepository; -import org.jackhuang.hmcl.game.ModpackHelper; -import org.jackhuang.hmcl.mod.Modpack; -import org.jackhuang.hmcl.setting.Accounts; -import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.setting.Profiles; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.task.TaskExecutor; -import org.jackhuang.hmcl.ui.account.AccountAdvancedListItem; -import org.jackhuang.hmcl.ui.account.AddAccountPane; -import org.jackhuang.hmcl.ui.construct.AdvancedListBox; -import org.jackhuang.hmcl.ui.construct.AdvancedListItem; -import org.jackhuang.hmcl.ui.profile.ProfileAdvancedListItem; -import org.jackhuang.hmcl.ui.versions.GameAdvancedListItem; -import org.jackhuang.hmcl.ui.versions.Versions; -import org.jackhuang.hmcl.util.io.CompressingUtils; - -import java.io.File; -import java.util.concurrent.atomic.AtomicReference; - -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -public final class LeftPaneController extends AdvancedListBox { - - public LeftPaneController() { - - AccountAdvancedListItem accountListItem = new AccountAdvancedListItem(); - accountListItem.setOnAction(e -> Controllers.navigate(Controllers.getAccountListPage())); - accountListItem.accountProperty().bind(Accounts.selectedAccountProperty()); - - GameAdvancedListItem gameListItem = new GameAdvancedListItem(); - gameListItem.actionButtonVisibleProperty().bind(Profiles.selectedVersionProperty().isNotNull()); - gameListItem.setOnAction(e -> { - Profile profile = Profiles.getSelectedProfile(); - String version = Profiles.getSelectedVersion(); - if (version == null) { - Controllers.navigate(Controllers.getGameListPage()); - } else { - Versions.modifyGameSettings(profile, version); - } - }); - - ProfileAdvancedListItem profileListItem = new ProfileAdvancedListItem(); - profileListItem.setOnAction(e -> Controllers.navigate(Controllers.getProfileListPage())); - profileListItem.profileProperty().bind(Profiles.selectedProfileProperty()); - - AdvancedListItem gameItem = new AdvancedListItem(); - gameItem.setImage(new Image("/assets/img/bookshelf.png")); - gameItem.setTitle(i18n("version.manage")); - gameItem.setOnAction(e -> Controllers.navigate(Controllers.getGameListPage())); - - AdvancedListItem launcherSettingsItem = new AdvancedListItem(); - launcherSettingsItem.setImage(new Image("/assets/img/command.png")); - launcherSettingsItem.setTitle(i18n("settings.launcher")); - launcherSettingsItem.setOnAction(e -> Controllers.navigate(Controllers.getSettingsPage())); - - this - .startCategory(i18n("account").toUpperCase()) - .add(accountListItem) - .startCategory(i18n("version").toUpperCase()) - .add(gameListItem) - .add(gameItem) - .startCategory(i18n("profile.title").toUpperCase()) - .add(profileListItem) - .startCategory(i18n("launcher").toUpperCase()) - .add(launcherSettingsItem); - - EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).register(event -> onRefreshedVersions((HMCLGameRepository) event.getSource())); - - Profile profile = Profiles.getSelectedProfile(); - if (profile != null && profile.getRepository().isLoaded()) - onRefreshedVersions(Profiles.selectedProfileProperty().get().getRepository()); - } - - // ==== Accounts ==== - public void checkAccount() { - if (Accounts.getAccounts().isEmpty()) - Platform.runLater(this::addNewAccount); - } - - private void addNewAccount() { - Controllers.dialog(new AddAccountPane()); - } - // ==== - - private boolean checkedModpack = false; - - private void onRefreshedVersions(HMCLGameRepository repository) { - JFXUtilities.runInFX(() -> { - if (!checkedModpack) { - checkedModpack = true; - - if (repository.getVersionCount() == 0) { - File modpackFile = new File("modpack.zip").getAbsoluteFile(); - if (modpackFile.exists()) { - Task.ofResult(() -> CompressingUtils.findSuitableEncoding(modpackFile.toPath())) - .thenResult(encoding -> ModpackHelper.readModpackManifest(modpackFile.toPath(), encoding)) - .thenResult(modpack -> { - AtomicReference region = new AtomicReference<>(); - TaskExecutor executor = ModpackHelper.getInstallTask(repository.getProfile(), modpackFile, modpack.getName(), modpack) - .with(Task.of(Schedulers.javafx(), this::checkAccount)).executor(); - region.set(Controllers.taskDialog(executor, i18n("modpack.installing"))); - executor.start(); - return null; - }).start(); - } - } - } - - checkAccount(); - }); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPage.java index 5e6efd8c89..4025271e02 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPage.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,18 +18,11 @@ package org.jackhuang.hmcl.ui; import javafx.beans.property.BooleanProperty; -import javafx.beans.property.ListProperty; import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleListProperty; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; import javafx.scene.Node; -import javafx.scene.control.Control; import javafx.scene.control.Skin; -public abstract class ListPage extends Control { - private final ListProperty items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList()); - private final BooleanProperty loading = new SimpleBooleanProperty(this, "loading", false); +public abstract class ListPage extends ListPageBase { private final BooleanProperty refreshable = new SimpleBooleanProperty(this, "refreshable", false); public abstract void add(); @@ -42,30 +35,6 @@ protected Skin createDefaultSkin() { return new ListPageSkin(this); } - public ObservableList getItems() { - return items.get(); - } - - public void setItems(ObservableList items) { - this.items.set(items); - } - - public ListProperty itemsProperty() { - return items; - } - - public boolean isLoading() { - return loading.get(); - } - - public void setLoading(boolean loading) { - this.loading.set(loading); - } - - public BooleanProperty loadingProperty() { - return loading; - } - public boolean isRefreshable() { return refreshable.get(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageBase.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageBase.java new file mode 100644 index 0000000000..38911bbddf --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageBase.java @@ -0,0 +1,88 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui; + +import javafx.beans.property.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.event.Event; +import javafx.event.EventHandler; +import javafx.scene.control.Control; + +import static org.jackhuang.hmcl.ui.construct.SpinnerPane.FAILED_ACTION; + +public class ListPageBase extends Control { + private final ListProperty items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList()); + private final BooleanProperty loading = new SimpleBooleanProperty(this, "loading", false); + private final StringProperty failedReason = new SimpleStringProperty(this, "failed"); + + public ObservableList getItems() { + return items.get(); + } + + public void setItems(ObservableList items) { + this.items.set(items); + } + + public ListProperty itemsProperty() { + return items; + } + + public boolean isLoading() { + return loading.get(); + } + + public void setLoading(boolean loading) { + this.loading.set(loading); + } + + public BooleanProperty loadingProperty() { + return loading; + } + + public String getFailedReason() { + return failedReason.get(); + } + + public StringProperty failedReasonProperty() { + return failedReason; + } + + public void setFailedReason(String failedReason) { + this.failedReason.set(failedReason); + } + + public final ObjectProperty> onFailedActionProperty() { + return onFailedAction; + } + + public final void setOnFailedAction(EventHandler value) { + onFailedActionProperty().set(value); + } + + public final EventHandler getOnFailedAction() { + return onFailedActionProperty().get(); + } + + private ObjectProperty> onFailedAction = new SimpleObjectProperty>(this, "onFailedAction") { + @Override + protected void invalidated() { + setEventHandler(FAILED_ACTION, get()); + } + }; +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageSkin.java index 1e8cc6897c..1927b31726 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageSkin.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,13 +18,13 @@ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.JFXButton; -import com.jfoenix.controls.JFXScrollPane; - import javafx.beans.binding.Bindings; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.ScrollPane; import javafx.scene.control.SkinBase; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Pane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.setting.Theme; @@ -36,6 +36,9 @@ public ListPageSkin(ListPage skinnable) { super(skinnable); SpinnerPane spinnerPane = new SpinnerPane(); + spinnerPane.getStyleClass().add("large-spinner-pane"); + Pane placeholder = new Pane(); + VBox list = new VBox(); StackPane contentPane = new StackPane(); { @@ -43,50 +46,59 @@ public ListPageSkin(ListPage skinnable) { { scrollPane.setFitToWidth(true); - VBox list = new VBox(); list.maxWidthProperty().bind(scrollPane.widthProperty()); list.setSpacing(10); - list.setPadding(new Insets(10)); + + VBox content = new VBox(); + content.getChildren().setAll(list, placeholder); Bindings.bindContent(list.getChildren(), skinnable.itemsProperty()); - scrollPane.setContent(list); - JFXScrollPane.smoothScrolling(scrollPane); + scrollPane.setContent(content); + FXUtils.smoothScrolling(scrollPane); } VBox vBox = new VBox(); { + vBox.getStyleClass().add("card-list"); vBox.setAlignment(Pos.BOTTOM_RIGHT); vBox.setPickOnBounds(false); - vBox.setPadding(new Insets(15)); - vBox.setSpacing(15); - JFXButton btnAdd = new JFXButton(); + JFXButton btnAdd = FXUtils.newRaisedButton(""); FXUtils.setLimitWidth(btnAdd, 40); FXUtils.setLimitHeight(btnAdd, 40); - btnAdd.getStyleClass().setAll("jfx-button-raised-round"); - btnAdd.setButtonType(JFXButton.ButtonType.RAISED); - btnAdd.setGraphic(SVG.plus(Theme.whiteFillBinding(), -1, -1)); - btnAdd.setOnMouseClicked(e -> skinnable.add()); + btnAdd.setGraphic(SVG.ADD.createIcon(Theme.whiteFill(), -1)); + btnAdd.setOnAction(e -> skinnable.add()); JFXButton btnRefresh = new JFXButton(); FXUtils.setLimitWidth(btnRefresh, 40); FXUtils.setLimitHeight(btnRefresh, 40); - btnRefresh.getStyleClass().setAll("jfx-button-raised-round"); + btnRefresh.getStyleClass().add("jfx-button-raised-round"); btnRefresh.setButtonType(JFXButton.ButtonType.RAISED); - btnRefresh.setGraphic(SVG.refresh(Theme.whiteFillBinding(), -1, -1)); - btnRefresh.setOnMouseClicked(e -> skinnable.refresh()); + btnRefresh.setGraphic(SVG.REFRESH.createIcon(Theme.whiteFill(), -1)); + btnRefresh.setOnAction(e -> skinnable.refresh()); vBox.getChildren().setAll(btnAdd); FXUtils.onChangeAndOperate(skinnable.refreshableProperty(), refreshable -> { - if (refreshable) vBox.getChildren().setAll(btnRefresh, btnAdd); - else vBox.getChildren().setAll(btnAdd); + if (refreshable) { + list.setPadding(new Insets(10, 10, 15 + 40 + 15 + 40 + 15, 10)); + vBox.getChildren().setAll(btnRefresh, btnAdd); + } else { + list.setPadding(new Insets(10, 10, 15 + 40 + 15, 10)); + vBox.getChildren().setAll(btnAdd); + } }); } - contentPane.getChildren().setAll(scrollPane, vBox); + // Keep a blank space to prevent buttons from blocking up mod items. + BorderPane group = new BorderPane(); + group.setPickOnBounds(false); + group.setBottom(vBox); + placeholder.minHeightProperty().bind(vBox.heightProperty()); + + contentPane.getChildren().setAll(scrollPane, group); } spinnerPane.loadingProperty().bind(skinnable.loadingProperty()); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java index 901f2837c9..dd0c6c896a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,228 +17,419 @@ */ package org.jackhuang.hmcl.ui; +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXCheckBox; +import com.jfoenix.controls.JFXComboBox; +import com.jfoenix.controls.JFXListView; +import javafx.application.Platform; +import javafx.beans.InvalidationListener; import javafx.beans.binding.Bindings; -import javafx.beans.property.ReadOnlyIntegerProperty; -import javafx.beans.property.ReadOnlyIntegerWrapper; -import javafx.concurrent.Worker; -import javafx.fxml.FXML; +import javafx.beans.property.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.scene.Scene; -import javafx.scene.control.ComboBox; -import javafx.scene.control.ToggleButton; -import javafx.scene.image.Image; -import javafx.scene.layout.StackPane; -import javafx.scene.web.WebEngine; -import javafx.scene.web.WebView; +import javafx.scene.control.Label; +import javafx.scene.control.*; +import javafx.scene.input.KeyCode; +import javafx.scene.input.MouseButton; +import javafx.scene.layout.*; import javafx.stage.Stage; -import org.jackhuang.hmcl.event.Event; -import org.jackhuang.hmcl.event.EventManager; -import org.jackhuang.hmcl.game.LauncherHelper; +import org.jackhuang.hmcl.game.GameDumpGenerator; +import org.jackhuang.hmcl.game.Log; +import org.jackhuang.hmcl.setting.StyleSheets; +import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel; +import org.jackhuang.hmcl.ui.construct.SpinnerPane; +import org.jackhuang.hmcl.util.Holder; +import org.jackhuang.hmcl.util.CircularArrayList; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.Log4jLevel; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.io.IOUtils; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.Node; - -import java.util.concurrent.CountDownLatch; +import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.SystemUtils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.util.Lang.thread; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; /** - * * @author huangyuhui */ public final class LogWindow extends Stage { - private final ReadOnlyIntegerWrapper fatal = new ReadOnlyIntegerWrapper(0); - private final ReadOnlyIntegerWrapper error = new ReadOnlyIntegerWrapper(0); - private final ReadOnlyIntegerWrapper warn = new ReadOnlyIntegerWrapper(0); - private final ReadOnlyIntegerWrapper info = new ReadOnlyIntegerWrapper(0); - private final ReadOnlyIntegerWrapper debug = new ReadOnlyIntegerWrapper(0); - private final LogWindowImpl impl = new LogWindowImpl(); - private final CountDownLatch latch = new CountDownLatch(1); - public final EventManager onDone = new EventManager<>(); + private static final Log4jLevel[] LEVELS = {Log4jLevel.FATAL, Log4jLevel.ERROR, Log4jLevel.WARN, Log4jLevel.INFO, Log4jLevel.DEBUG}; - public LogWindow() { - setScene(new Scene(impl, 800, 480)); - getScene().getStylesheets().addAll(config().getTheme().getStylesheets()); - setTitle(i18n("logwindow.title")); - getIcons().add(new Image("/assets/img/icon.png")); + private final CircularArrayList logs; + private final Map levelCountMap = new EnumMap<>(Log4jLevel.class); + private final Map levelShownMap = new EnumMap<>(Log4jLevel.class); + + { + for (Log4jLevel level : Log4jLevel.values()) { + levelCountMap.put(level, new SimpleIntegerProperty()); + levelShownMap.put(level, new SimpleBooleanProperty(true)); + } } - public LogWindow(String text) { - this(); + private final LogWindowImpl impl; + private final ManagedProcess gameProcess; - onDone.register(() -> logLine(text, Log4jLevel.INFO)); + public LogWindow(ManagedProcess gameProcess) { + this(gameProcess, new CircularArrayList<>()); } - public ReadOnlyIntegerProperty fatalProperty() { - return fatal.getReadOnlyProperty(); - } + public LogWindow(ManagedProcess gameProcess, CircularArrayList logs) { + this.logs = logs; + this.impl = new LogWindowImpl(); + setScene(new Scene(impl, 800, 480)); + StyleSheets.init(getScene()); + setTitle(i18n("logwindow.title")); + FXUtils.setIcon(this); - public int getFatal() { - return fatal.get(); - } + for (SimpleBooleanProperty property : levelShownMap.values()) { + property.addListener(o -> shakeLogs()); + } - public ReadOnlyIntegerProperty errorProperty() { - return error.getReadOnlyProperty(); + this.gameProcess = gameProcess; } - public int getError() { - return error.get(); - } + public void logLine(Log log) { + Log4jLevel level = log.getLevel(); + logs.add(log); + if (levelShownMap.get(level).get()) + impl.listView.getItems().add(log); - public ReadOnlyIntegerProperty warnProperty() { - return warn.getReadOnlyProperty(); + SimpleIntegerProperty property = levelCountMap.get(log.getLevel()); + property.set(property.get() + 1); + checkLogCount(); + autoScroll(); } - public int getWarn() { - return warn.get(); - } + public void logLines(List logs) { + for (Log log : logs) { + Log4jLevel level = log.getLevel(); + this.logs.add(log); + if (levelShownMap.get(level).get()) + impl.listView.getItems().add(log); - public ReadOnlyIntegerProperty infoProperty() { - return info.getReadOnlyProperty(); + SimpleIntegerProperty property = levelCountMap.get(log.getLevel()); + property.set(property.get() + 1); + } + checkLogCount(); + autoScroll(); } - public int getInfo() { - return info.get(); + private void shakeLogs() { + impl.listView.getItems().setAll(logs.stream().filter(log -> levelShownMap.get(log.getLevel()).get()).collect(Collectors.toList())); + autoScroll(); } - public ReadOnlyIntegerProperty debugProperty() { - return debug.getReadOnlyProperty(); - } + private void checkLogCount() { + int nRemove = logs.size() - Log.getLogLines(); + if (nRemove <= 0) + return; - public int getDebug() { - return debug.get(); - } + ObservableList items = impl.listView.getItems(); + int itemsSize = items.size(); + int count = 0; - public void logLine(String line, Log4jLevel level) { - Element div = impl.document.createElement("div"); - // a
 element to prevent multiple spaces and tabs being removed.
-        Element pre = impl.document.createElement("pre");
-        pre.setTextContent(line);
-        div.appendChild(pre);
-        impl.body.appendChild(div);
-        impl.engine.executeScript("checkNewLog(\"" + level.name().toLowerCase() + "\");scrollToBottom();");
-
-        switch (level) {
-            case FATAL:
-                fatal.set(fatal.get() + 1);
-                break;
-            case ERROR:
-                error.set(error.get() + 1);
-                break;
-            case WARN:
-                warn.set(warn.get() + 1);
-                break;
-            case INFO:
-                info.set(info.get() + 1);
-                break;
-            case DEBUG:
-                debug.set(debug.get() + 1);
-                break;
-            default:
-                // ignore
-                break;
+        for (int i = 0; i < nRemove; i++) {
+            Log removedLog = logs.removeFirst();
+            if (itemsSize > count && items.get(count) == removedLog)
+                count++;
         }
+
+        items.remove(0, count);
     }
 
-    public void waitForLoaded() throws InterruptedException {
-        latch.await();
+    private void autoScroll() {
+        if (!impl.listView.getItems().isEmpty() && impl.autoScroll.get())
+            impl.listView.scrollTo(impl.listView.getItems().size() - 1);
     }
 
-    public class LogWindowImpl extends StackPane {
-
-        @FXML
-        private WebView webView;
-        @FXML
-        private ToggleButton btnFatals;
-        @FXML
-        private ToggleButton btnErrors;
-        @FXML
-        private ToggleButton btnWarns;
-        @FXML
-        private ToggleButton btnInfos;
-        @FXML
-        private ToggleButton btnDebugs;
-        @FXML
-        private ComboBox cboLines;
-
-        final WebEngine engine;
-        Node body;
-        Document document;
+    private final class LogWindowImpl extends Control {
+
+        private final ListView listView = new JFXListView<>();
+        private final BooleanProperty autoScroll = new SimpleBooleanProperty();
+        private final StringProperty[] buttonText = new StringProperty[LEVELS.length];
+        private final BooleanProperty[] showLevel = new BooleanProperty[LEVELS.length];
+        private final JFXComboBox cboLines = new JFXComboBox<>();
 
         LogWindowImpl() {
-            FXUtils.loadFXML(this, "/assets/fxml/log.fxml");
-
-            engine = webView.getEngine();
-            engine.loadContent(Lang.ignoringException(() -> IOUtils.readFullyAsString(getClass().getResourceAsStream("/assets/log-window-content.html")))
-                    .replace("${FONT}", config().getFontSize() + "px \"" + config().getFontFamily() + "\""));
-            engine.getLoadWorker().stateProperty().addListener((a, b, newValue) -> {
-                if (newValue == Worker.State.SUCCEEDED) {
-                    document = engine.getDocument();
-                    body = document.getElementsByTagName("body").item(0);
-                    engine.executeScript("limitedLogs=" + config().getLogLines());
-                    latch.countDown();
-                    onDone.fireEvent(new Event(LogWindow.this));
-                }
-            });
+            getStyleClass().add("log-window");
+
+            listView.setItems(FXCollections.observableList(new CircularArrayList<>(logs.size())));
+
+            for (int i = 0; i < LEVELS.length; i++) {
+                buttonText[i] = new SimpleStringProperty();
+                showLevel[i] = new SimpleBooleanProperty(true);
+            }
+
+            cboLines.getItems().setAll(500, 2000, 5000, 10000);
+            cboLines.setValue(Log.getLogLines());
+            cboLines.getSelectionModel().selectedItemProperty().addListener((a, b, newValue) -> config().setLogLines(newValue));
+
+            for (int i = 0; i < LEVELS.length; ++i) {
+                buttonText[i].bind(Bindings.concat(levelCountMap.get(LEVELS[i]), " " + LEVELS[i].name().toLowerCase(Locale.ROOT) + "s"));
+                levelShownMap.get(LEVELS[i]).bind(showLevel[i]);
+            }
+        }
+
+        private void onTerminateGame() {
+            LogWindow.this.gameProcess.stop();
+        }
 
-            boolean flag = false;
-            for (String i : cboLines.getItems())
-                if (Integer.toString(config().getLogLines()).equals(i)) {
-                    cboLines.getSelectionModel().select(i);
-                    flag = true;
+        private void onClear() {
+            impl.listView.getItems().clear();
+            logs.clear();
+        }
+
+        private void onExportLogs() {
+            thread(() -> {
+                Path logFile = Paths.get("minecraft-exported-logs-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".log").toAbsolutePath();
+                try {
+                    Files.write(logFile, logs.stream().map(Log::getLog).collect(Collectors.toList()));
+                } catch (IOException e) {
+                    LOG.warning("Failed to export logs", e);
+                    return;
                 }
 
-            cboLines.getSelectionModel().selectedItemProperty().addListener((a, b, newValue) -> {
-                config().setLogLines(newValue == null ? 100 : Integer.parseInt(newValue));
-                engine.executeScript("limitedLogs=" + config().getLogLines());
+                Platform.runLater(() -> {
+                    Alert alert = new Alert(Alert.AlertType.INFORMATION, i18n("settings.launcher.launcher_log.export.success", logFile));
+                    alert.setTitle(i18n("settings.launcher.launcher_log.export"));
+                    alert.showAndWait();
+                });
+
+                FXUtils.showFileInExplorer(logFile);
             });
+        }
 
-            if (!flag)
-                cboLines.getSelectionModel().select(0);
+        private void onExportDump(SpinnerPane pane) {
+            assert SystemUtils.supportJVMAttachment();
 
-            btnFatals.textProperty().bind(Bindings.createStringBinding(() -> Integer.toString(fatal.get()) + " fatals", fatal));
-            btnErrors.textProperty().bind(Bindings.createStringBinding(() -> Integer.toString(error.get()) + " errors", error));
-            btnWarns.textProperty().bind(Bindings.createStringBinding(() -> Integer.toString(warn.get()) + " warns", warn));
-            btnInfos.textProperty().bind(Bindings.createStringBinding(() -> Integer.toString(info.get()) + " infos", info));
-            btnDebugs.textProperty().bind(Bindings.createStringBinding(() -> Integer.toString(debug.get()) + " debugs", debug));
+            pane.setLoading(true);
 
-            btnFatals.selectedProperty().addListener(o -> specificChanged());
-            btnErrors.selectedProperty().addListener(o -> specificChanged());
-            btnWarns.selectedProperty().addListener(o -> specificChanged());
-            btnInfos.selectedProperty().addListener(o -> specificChanged());
-            btnDebugs.selectedProperty().addListener(o -> specificChanged());
-        }
+            thread(() -> {
+                Path dumpFile = Paths.get("minecraft-exported-jstack-dump-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".log").toAbsolutePath();
+
+                try {
+                    if (gameProcess.isRunning()) {
+                        GameDumpGenerator.writeDumpTo(gameProcess.getProcess().pid(), dumpFile);
+                        FXUtils.showFileInExplorer(dumpFile);
+                    }
+                } catch (Throwable e) {
+                    LOG.warning("Failed to create minecraft jstack dump", e);
 
-        private void specificChanged() {
-            String res = "";
-            if (btnFatals.isSelected())
-                res += "\"fatal\", ";
-            if (btnErrors.isSelected())
-                res += "\"error\", ";
-            if (btnWarns.isSelected())
-                res += "\"warn\", ";
-            if (btnInfos.isSelected())
-                res += "\"info\", ";
-            if (btnDebugs.isSelected())
-                res += "\"debug\", ";
-            if (StringUtils.isNotBlank(res))
-                res = StringUtils.substringBeforeLast(res, ", ");
-            engine.executeScript("specific([" + res + "])");
+                    Platform.runLater(() -> {
+                        Alert alert = new Alert(Alert.AlertType.ERROR, i18n("logwindow.export_dump"));
+                        alert.setTitle(i18n("message.error"));
+                        alert.showAndWait();
+                    });
+                }
+
+                Platform.runLater(() -> pane.setLoading(false));
+            });
         }
 
-        @FXML
-        private void onTerminateGame() {
-            LauncherHelper.stopManagedProcesses();
+        @Override
+        protected Skin createDefaultSkin() {
+            return new LogWindowSkin(this);
         }
+    }
 
-        @FXML
-        private void onClear() {
-            engine.executeScript("clear()");
+    private static final class LogWindowSkin extends SkinBase {
+        private static final PseudoClass EMPTY = PseudoClass.getPseudoClass("empty");
+        private static final PseudoClass FATAL = PseudoClass.getPseudoClass("fatal");
+        private static final PseudoClass ERROR = PseudoClass.getPseudoClass("error");
+        private static final PseudoClass WARN = PseudoClass.getPseudoClass("warn");
+        private static final PseudoClass INFO = PseudoClass.getPseudoClass("info");
+        private static final PseudoClass DEBUG = PseudoClass.getPseudoClass("debug");
+        private static final PseudoClass TRACE = PseudoClass.getPseudoClass("trace");
+        private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
+
+        private final Set> selected = new HashSet<>();
+
+        LogWindowSkin(LogWindowImpl control) {
+            super(control);
+
+            VBox vbox = new VBox(3);
+            vbox.setPadding(new Insets(3, 0, 3, 0));
+            vbox.setStyle("-fx-background-color: white");
+            getChildren().setAll(vbox);
+
+            {
+                BorderPane borderPane = new BorderPane();
+                borderPane.setPadding(new Insets(0, 3, 0, 3));
+
+                {
+                    HBox hBox = new HBox(3);
+                    hBox.setPadding(new Insets(0, 0, 0, 4));
+                    hBox.setAlignment(Pos.CENTER_LEFT);
+
+                    Label label = new Label(i18n("logwindow.show_lines"));
+                    hBox.getChildren().setAll(label, control.cboLines);
+
+                    borderPane.setLeft(hBox);
+                }
+
+                {
+                    HBox hBox = new HBox(3);
+                    for (int i = 0; i < LEVELS.length; i++) {
+                        ToggleButton button = new ToggleButton();
+                        button.setStyle("-fx-background-color: " + FXUtils.toWeb(LEVELS[i].getColor()) + ";");
+                        button.getStyleClass().add("log-toggle");
+                        button.textProperty().bind(control.buttonText[i]);
+                        button.setSelected(true);
+                        control.showLevel[i].bind(button.selectedProperty());
+                        hBox.getChildren().add(button);
+                    }
+
+                    borderPane.setRight(hBox);
+                }
+
+                vbox.getChildren().add(borderPane);
+            }
+
+            {
+                ListView listView = control.listView;
+                listView.getItems().addListener((InvalidationListener) observable -> {
+                    if (!listView.getItems().isEmpty() && control.autoScroll.get())
+                        listView.scrollTo(listView.getItems().size() - 1);
+                });
+
+                listView.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT)
+                        + "\"; -fx-font-size: " + config().getFontSize() + "px;");
+                Holder lastCell = new Holder<>();
+                listView.setCellFactory(x -> new ListCell() {
+                    {
+                        x.setSelectionModel(new NoneMultipleSelectionModel<>());
+                        getStyleClass().add("log-window-list-cell");
+                        Region clippedContainer = (Region) listView.lookup(".clipped-container");
+                        if (clippedContainer != null) {
+                            maxWidthProperty().bind(clippedContainer.widthProperty());
+                            prefWidthProperty().bind(clippedContainer.widthProperty());
+                        }
+                        setPadding(new Insets(2));
+                        setWrapText(true);
+                        setGraphic(null);
+
+                        setOnMouseClicked(event -> {
+                            if (event.getButton() != MouseButton.PRIMARY)
+                                return;
+
+                            if (!event.isControlDown()) {
+                                for (ListCell logListCell : selected) {
+                                    if (logListCell != this) {
+                                        logListCell.pseudoClassStateChanged(SELECTED, false);
+                                        if (logListCell.getItem() != null) {
+                                            logListCell.getItem().setSelected(false);
+                                        }
+                                    }
+                                }
+
+                                selected.clear();
+                            }
+
+                            selected.add(this);
+                            pseudoClassStateChanged(SELECTED, true);
+                            if (getItem() != null) {
+                                getItem().setSelected(true);
+                            }
+
+                            event.consume();
+                        });
+                    }
+
+                    @Override
+                    protected void updateItem(Log item, boolean empty) {
+                        super.updateItem(item, empty);
+
+                        // https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
+                        if (this == lastCell.value && !isVisible())
+                            return;
+                        lastCell.value = this;
+
+                        pseudoClassStateChanged(EMPTY, empty);
+                        pseudoClassStateChanged(FATAL, !empty && item.getLevel() == Log4jLevel.FATAL);
+                        pseudoClassStateChanged(ERROR, !empty && item.getLevel() == Log4jLevel.ERROR);
+                        pseudoClassStateChanged(WARN, !empty && item.getLevel() == Log4jLevel.WARN);
+                        pseudoClassStateChanged(INFO, !empty && item.getLevel() == Log4jLevel.INFO);
+                        pseudoClassStateChanged(DEBUG, !empty && item.getLevel() == Log4jLevel.DEBUG);
+                        pseudoClassStateChanged(TRACE, !empty && item.getLevel() == Log4jLevel.TRACE);
+                        pseudoClassStateChanged(SELECTED, !empty && item.isSelected());
+
+                        if (empty) {
+                            setText(null);
+                        } else {
+                            setText(item.getLog());
+                        }
+                    }
+                });
+
+                listView.setOnKeyPressed(event -> {
+                    if (event.isControlDown() && event.getCode() == KeyCode.C) {
+                        StringBuilder stringBuilder = new StringBuilder();
+
+                        for (Log item : listView.getItems()) {
+                            if (item != null && item.isSelected()) {
+                                if (item.getLog() != null)
+                                    stringBuilder.append(item.getLog());
+                                stringBuilder.append('\n');
+                            }
+                        }
+
+                        FXUtils.copyText(stringBuilder.toString());
+                    }
+                });
+
+                VBox.setVgrow(listView, Priority.ALWAYS);
+                vbox.getChildren().add(listView);
+            }
+
+            {
+                BorderPane bottom = new BorderPane();
+
+                HBox hBox = new HBox(3);
+                bottom.setRight(hBox);
+                hBox.setAlignment(Pos.CENTER_RIGHT);
+                hBox.setPadding(new Insets(0, 3, 0, 3));
+
+                JFXCheckBox autoScrollCheckBox = new JFXCheckBox(i18n("logwindow.autoscroll"));
+                autoScrollCheckBox.setSelected(true);
+                control.autoScroll.bind(autoScrollCheckBox.selectedProperty());
+
+                JFXButton exportLogsButton = new JFXButton(i18n("button.export"));
+                exportLogsButton.setOnAction(e -> getSkinnable().onExportLogs());
+
+                JFXButton terminateButton = new JFXButton(i18n("logwindow.terminate_game"));
+                terminateButton.setOnAction(e -> getSkinnable().onTerminateGame());
+
+                SpinnerPane exportDumpPane = new SpinnerPane();
+                JFXButton exportDumpButton = new JFXButton(i18n("logwindow.export_dump"));
+                if (SystemUtils.supportJVMAttachment()) {
+                    exportDumpButton.setOnAction(e -> getSkinnable().onExportDump(exportDumpPane));
+                } else {
+                    exportDumpButton.setTooltip(new Tooltip(i18n("logwindow.export_dump.no_dependency")));
+                    exportDumpButton.setDisable(true);
+                }
+                exportDumpPane.setContent(exportDumpButton);
+
+                JFXButton clearButton = new JFXButton(i18n("button.clear"));
+                clearButton.setOnAction(e -> getSkinnable().onClear());
+                hBox.getChildren().setAll(autoScrollCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton);
+
+                vbox.getChildren().add(bottom);
+            }
         }
     }
 }
diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/MainPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/MainPage.java
deleted file mode 100644
index 6b6d65a545..0000000000
--- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/MainPage.java
+++ /dev/null
@@ -1,257 +0,0 @@
-/*
- * Hello Minecraft! Launcher
- * Copyright (C) 2019  huangyuhui  and contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see .
- */
-package org.jackhuang.hmcl.ui;
-
-import com.jfoenix.controls.JFXButton;
-import com.jfoenix.controls.JFXPopup;
-import javafx.animation.KeyFrame;
-import javafx.animation.KeyValue;
-import javafx.animation.Timeline;
-import javafx.beans.binding.Bindings;
-import javafx.beans.property.*;
-import javafx.collections.FXCollections;
-import javafx.collections.ObservableList;
-import javafx.geometry.Insets;
-import javafx.geometry.Pos;
-import javafx.scene.Node;
-import javafx.scene.control.Label;
-import javafx.scene.layout.HBox;
-import javafx.scene.layout.StackPane;
-import javafx.scene.layout.VBox;
-import javafx.scene.paint.Color;
-import javafx.scene.shape.Rectangle;
-import javafx.util.Duration;
-import org.jackhuang.hmcl.setting.Profile;
-import org.jackhuang.hmcl.setting.Profiles;
-import org.jackhuang.hmcl.setting.Theme;
-import org.jackhuang.hmcl.ui.construct.PopupMenu;
-import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
-import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
-import org.jackhuang.hmcl.ui.versions.Versions;
-import org.jackhuang.hmcl.upgrade.RemoteVersion;
-import org.jackhuang.hmcl.upgrade.UpdateChecker;
-import org.jackhuang.hmcl.upgrade.UpdateHandler;
-
-import static org.jackhuang.hmcl.ui.FXUtils.SINE;
-import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
-
-public final class MainPage extends StackPane implements DecoratorPage {
-    private final ReadOnlyStringWrapper title = new ReadOnlyStringWrapper(this, "title", i18n("main_page"));
-
-    private final PopupMenu menu = new PopupMenu();
-    private final JFXPopup popup = new JFXPopup(menu);
-
-    private final StringProperty currentGame = new SimpleStringProperty(this, "currentGame");
-    private final BooleanProperty showUpdate = new SimpleBooleanProperty(this, "showUpdate");
-    private final StringProperty latestVersion = new SimpleStringProperty(this, "latestVersion");
-    private final ObservableList versions = FXCollections.observableArrayList();
-
-    private StackPane updatePane;
-    private JFXButton menuButton;
-
-    {
-        setPadding(new Insets(25));
-
-        updatePane = new StackPane();
-        updatePane.setVisible(false);
-        updatePane.getStyleClass().add("bubble");
-        FXUtils.setLimitWidth(updatePane, 230);
-        FXUtils.setLimitHeight(updatePane, 55);
-        StackPane.setAlignment(updatePane, Pos.TOP_RIGHT);
-        updatePane.setOnMouseClicked(e -> onUpgrade());
-        FXUtils.onChange(showUpdateProperty(), this::doAnimation);
-
-        {
-            HBox hBox = new HBox();
-            hBox.setSpacing(12);
-            hBox.setAlignment(Pos.CENTER_LEFT);
-            StackPane.setAlignment(hBox, Pos.CENTER_LEFT);
-            StackPane.setMargin(hBox, new Insets(9, 12, 9, 16));
-            {
-                Label lblIcon = new Label();
-                lblIcon.setGraphic(SVG.update(Theme.whiteFillBinding(), 20, 20));
-
-                TwoLineListItem prompt = new TwoLineListItem();
-                prompt.setSubtitle(i18n("update.bubble.subtitle"));
-                prompt.setPickOnBounds(false);
-                prompt.titleProperty().bind(latestVersionProperty());
-
-                hBox.getChildren().setAll(lblIcon, prompt);
-            }
-
-            JFXButton closeUpdateButton = new JFXButton();
-            closeUpdateButton.setGraphic(SVG.close(Theme.whiteFillBinding(), 10, 10));
-            StackPane.setAlignment(closeUpdateButton, Pos.TOP_RIGHT);
-            closeUpdateButton.getStyleClass().add("toggle-icon-tiny");
-            StackPane.setMargin(closeUpdateButton, new Insets(5));
-            closeUpdateButton.setOnMouseClicked(e -> closeUpdateBubble());
-
-            updatePane.getChildren().setAll(hBox, closeUpdateButton);
-        }
-
-        StackPane launchPane = new StackPane();
-        launchPane.setMaxWidth(230);
-        launchPane.setMaxHeight(55);
-        StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
-        {
-            JFXButton launchButton = new JFXButton();
-            launchButton.setPrefWidth(230);
-            launchButton.setPrefHeight(55);
-            launchButton.setButtonType(JFXButton.ButtonType.RAISED);
-            launchButton.getStyleClass().add("jfx-button-raised");
-            launchButton.setOnMouseClicked(e -> launch());
-            launchButton.setClip(new Rectangle(-100, -100, 310, 200));
-            {
-                VBox graphic = new VBox();
-                graphic.setAlignment(Pos.CENTER);
-                graphic.setTranslateX(-7);
-                graphic.setMaxWidth(200);
-                Label launchLabel = new Label(i18n("version.launch"));
-                launchLabel.setStyle("-fx-font-size: 16px;");
-                Label currentLabel = new Label();
-                currentLabel.setStyle("-fx-font-size: 12px;");
-                currentLabel.textProperty().bind(currentGameProperty());
-                graphic.getChildren().setAll(launchLabel, currentLabel);
-
-                launchButton.setGraphic(graphic);
-            }
-
-            Rectangle separator = new Rectangle();
-            separator.getStyleClass().add("darker-fill");
-            separator.setWidth(1);
-            separator.setHeight(57);
-            separator.setTranslateX(95);
-            separator.setMouseTransparent(true);
-
-            menuButton = new JFXButton();
-            menuButton.setPrefHeight(55);
-            menuButton.setPrefWidth(230);
-            menuButton.setButtonType(JFXButton.ButtonType.RAISED);
-            menuButton.getStyleClass().add("jfx-button-raised");
-            menuButton.setStyle("-fx-font-size: 15px;");
-            menuButton.setOnMouseClicked(e -> onMenu());
-            menuButton.setClip(new Rectangle(211, -100, 100, 200));
-            StackPane graphic = new StackPane();
-            Node svg = SVG.triangle(Theme.whiteFillBinding(), 10, 10);
-            StackPane.setAlignment(svg, Pos.CENTER_RIGHT);
-            graphic.getChildren().setAll(svg);
-            graphic.setTranslateX(12);
-            menuButton.setGraphic(graphic);
-
-            launchPane.getChildren().setAll(launchButton, separator, menuButton);
-        }
-
-        getChildren().setAll(updatePane, launchPane);
-
-        menu.setMaxHeight(365);
-        menu.setMaxWidth(545);
-        menu.setAlwaysShowingVBar(true);
-        menu.setOnMouseClicked(e -> popup.hide());
-        Bindings.bindContent(menu.getContent(), versions);
-    }
-
-    private void doAnimation(boolean show) {
-        Duration duration = Duration.millis(320);
-        Timeline nowAnimation = new Timeline();
-        nowAnimation.getKeyFrames().addAll(
-                new KeyFrame(Duration.ZERO,
-                        new KeyValue(updatePane.translateXProperty(), show ? 260 : 0, SINE)),
-                new KeyFrame(duration,
-                        new KeyValue(updatePane.translateXProperty(), show ? 0 : 260, SINE)));
-        if (show) nowAnimation.getKeyFrames().add(
-                new KeyFrame(Duration.ZERO, e -> updatePane.setVisible(true)));
-        else nowAnimation.getKeyFrames().add(
-                new KeyFrame(duration, e -> updatePane.setVisible(false)));
-        nowAnimation.play();
-    }
-
-    private void launch() {
-        Profile profile = Profiles.getSelectedProfile();
-        Versions.launch(profile, profile.getSelectedVersion());
-    }
-
-    private void onMenu() {
-        popup.show(menuButton, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.RIGHT, 0, -menuButton.getHeight());
-    }
-
-    private void onUpgrade() {
-        RemoteVersion target = UpdateChecker.getLatestVersion();
-        if (target == null) {
-            return;
-        }
-        UpdateHandler.updateFrom(target);
-    }
-
-    private void closeUpdateBubble() {
-        showUpdate.unbind();
-        showUpdate.set(false);
-    }
-
-    public String getTitle() {
-        return title.get();
-    }
-
-    @Override
-    public ReadOnlyStringProperty titleProperty() {
-        return title.getReadOnlyProperty();
-    }
-
-    public void setTitle(String title) {
-        this.title.set(title);
-    }
-
-    public String getCurrentGame() {
-        return currentGame.get();
-    }
-
-    public StringProperty currentGameProperty() {
-        return currentGame;
-    }
-
-    public void setCurrentGame(String currentGame) {
-        this.currentGame.set(currentGame);
-    }
-
-    public boolean isShowUpdate() {
-        return showUpdate.get();
-    }
-
-    public BooleanProperty showUpdateProperty() {
-        return showUpdate;
-    }
-
-    public void setShowUpdate(boolean showUpdate) {
-        this.showUpdate.set(showUpdate);
-    }
-
-    public String getLatestVersion() {
-        return latestVersion.get();
-    }
-
-    public StringProperty latestVersionProperty() {
-        return latestVersion;
-    }
-
-    public void setLatestVersion(String latestVersion) {
-        this.latestVersion.set(latestVersion);
-    }
-
-    public ObservableList getVersions() {
-        return versions;
-    }
-}
diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java
index 3f18c7678e..83ff706a1f 100644
--- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java
+++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java
@@ -1,6 +1,6 @@
 /*
  * Hello Minecraft! Launcher
- * Copyright (C) 2019  huangyuhui  and contributors
+ * Copyright (C) 2021  huangyuhui  and contributors
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -17,7 +17,7 @@
  */
 package org.jackhuang.hmcl.ui;
 
-import javafx.beans.binding.ObjectBinding;
+import javafx.beans.value.ObservableValue;
 import javafx.geometry.Pos;
 import javafx.scene.Group;
 import javafx.scene.Node;
@@ -25,141 +25,138 @@
 import javafx.scene.paint.Paint;
 import javafx.scene.shape.SVGPath;
 
-public final class SVG {
-    private SVG() {
-    }
-
-    private static Node createSVGPath(String d, ObjectBinding fill, double width, double height) {
-        SVGPath path = new SVGPath();
-        path.getStyleClass().add("svg");
-        path.setContent(d);
-        path.fillProperty().bind(fill);
-
-        if (width < 0 || height < 0) {
+/**
+ * All vector icons used in the launcher.
+ * 

+ * Unless otherwise stated, + * these icons are from Material Symbols, + * with a style of outlined, a weight of 400, a grade of 0, and an optical size of 24 px. + * The view boxes of all icons are normalized to {@code 0 0 24 24}. + */ +public enum SVG { + ADD("M11 13H5V11H11V5H13V11H19V13H13V19H11V13Z"), + ADD_CIRCLE("M11 17H13V13H17V11H13V7H11V11H7V13H11V17ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"), + ALPHA_CIRCLE("M11,7H13A2,2 0 0,1 15,9V17H13V13H11V17H9V9A2,2 0 0,1 11,7M11,9V11H13V9H11M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2Z"), // Not Material + ARCHIVE("M12 18 16 14 14.6 12.6 13 14.2V10H11V14.2L9.4 12.6 8 14 12 18ZM5 8V19H19V8H5ZM5 21Q4.175 21 3.5875 20.4125T3 19V6.525Q3 6.175 3.1125 5.85T3.45 5.25L4.7 3.725Q4.975 3.375 5.3875 3.1875T6.25 3H17.75Q18.2 3 18.6125 3.1875T19.3 3.725L20.55 5.25Q20.775 5.525 20.8875 5.85T21 6.525V19Q21 19.825 20.4125 20.4125T19 21H5ZM5.4 6H18.6L17.75 5H6.25L5.4 6ZM12 13.5Z"), + ARROW_BACK("M7.825 13 13.425 18.6 12 20 4 12 12 4 13.425 5.4 7.825 11H20V13H7.825Z"), + ARROW_DROP_DOWN("M12 15 7 10H17L12 15Z"), + ARROW_DROP_UP("M7 14 12 9 17 14H7Z"), + ARROW_FORWARD("M16.175 13H4V11H16.175L10.575 5.4 12 4 20 12 12 20 10.575 18.6 16.175 13Z"), + BETA_CIRCLE("M15,10.5C15,11.3 14.3,12 13.5,12C14.3,12 15,12.7 15,13.5V15A2,2 0 0,1 13,17H9V7H13A2,2 0 0,1 15,9V10.5M13,15V13H11V15H13M13,11V9H11V11H13M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z"), // Not Material + CANCEL("M8.4 17 12 13.4 15.6 17 17 15.6 13.4 12 17 8.4 15.6 7 12 10.6 8.4 7 7 8.4 10.6 12 7 15.6 8.4 17ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"), + CHAT("M6 14H14V12H6V14ZM6 11H18V9H6V11ZM6 8H18V6H6V8ZM2 22V4Q2 3.175 2.5875 2.5875T4 2H20Q20.825 2 21.4125 2.5875T22 4V16Q22 16.825 21.4125 17.4125T20 18H6L2 22ZM5.15 16H20V4H4V17.125L5.15 16ZM4 16V4 16Z"), + CHECK("M9.55 18 3.85 12.3 5.275 10.875 9.55 15.15 18.725 5.975 20.15 7.4 9.55 18Z"), + CHECKROOM("M3 20Q2.575 20 2.2875 19.7125T2 19Q2 18.75 2.1 18.5375T2.4 18.2L11 11.75V10Q11 9.575 11.3 9.2875T12.025 9Q12.65 9 13.075 8.55T13.5 7.475Q13.5 6.85 13.0625 6.425T12 6Q11.375 6 10.9375 6.4375T10.5 7.5H8.5Q8.5 6.05 9.525 5.025T12 4Q13.45 4 14.475 5.0125T15.5 7.475Q15.5 8.65 14.8125 9.575T13 10.85V11.75L21.6 18.2Q21.8 18.325 21.9 18.5375T22 19Q22 19.425 21.7125 19.7125T21 20H3ZM6 18H18L12 13.5 6 18Z"), + CHECK_CIRCLE("M10.6 16.6 17.65 9.55 16.25 8.15 10.6 13.8 7.75 10.95 6.35 12.35 10.6 16.6ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"), + CLOSE("M6.4 19 5 17.6 10.6 12 5 6.4 6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19Z"), + CONTENT_COPY("M9 18Q8.175 18 7.5875 17.4125T7 16V4Q7 3.175 7.5875 2.5875T9 2H18Q18.825 2 19.4125 2.5875T20 4V16Q20 16.825 19.4125 17.4125T18 18H9ZM9 16H18V4H9V16ZM5 22Q4.175 22 3.5875 21.4125T3 20V6H5V20H16V22H5ZM9 16V4 16Z"), + CREATE_NEW_FOLDER("M14 16h2V14h2V12H16V10H14v2H12v2h2v2ZM4 20q-.825 0-1.4125-.5875T2 18V6q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8V18q0 .825-.5875 1.4125T20 20H4Zm0-2H20V8H11.175l-2-2H4V18ZV6 18Z"), + DELETE("M7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM9 17H11V8H9V17ZM13 17H15V8H13V17ZM7 6V19 6Z"), + DELETE_FOREVER("M9.4 16.5 12 13.9 14.6 16.5 16 15.1 13.4 12.5 16 9.9 14.6 8.5 12 11.1 9.4 8.5 8 9.9 10.6 12.5 8 15.1 9.4 16.5ZM7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM7 6V19 6Z"), + DEPLOYED_CODE("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM12 10.85 17.925 7.425 12 4 6.075 7.425 12 10.85ZM4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725L4 17.7ZM12 12Z"), + DOWNLOAD("M12 16 7 11 8.4 9.55 11 12.15V4H13V12.15L15.6 9.55 17 11 12 16ZM6 20Q5.175 20 4.5875 19.4125T4 18V15H6V18H18V15H20V18Q20 18.825 19.4125 19.4125T18 20H6Z"), + DRESSER("M4 21V5Q4 4.175 4.5875 3.5875T6 3H18Q18.825 3 19.4125 3.5875T20 5V21H18V19H6V21H4ZM6 11H11V5H6V11ZM13 7H18V5H13V7ZM13 11H18V9H13V11ZM10 16H14V14H10V16ZM6 13V17H18V13H6ZM6 13V17 13Z"), + EDIT("M5 19H6.425L16.2 9.225 14.775 7.8 5 17.575V19ZM3 21V16.75L16.2 3.575Q16.5 3.3 16.8625 3.15T17.625 3Q18.025 3 18.4 3.15T19.05 3.6L20.425 5Q20.725 5.275 20.8625 5.65T21 6.4Q21 6.8 20.8625 7.1625T20.425 7.825L7.25 21H3ZM19 6.4 17.6 5 19 6.4ZM15.475 8.525 14.775 7.8 16.2 9.225 15.475 8.525Z"), + ERROR("M12 17Q12.425 17 12.7125 16.7125T13 16Q13 15.575 12.7125 15.2875T12 15Q11.575 15 11.2875 15.2875T11 16Q11 16.425 11.2875 16.7125T12 17ZM11 13H13V7H11V13ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"), + EXPLORE("M12 12Zm0 8q-3.325 0-5.6625-2.3375T4 12Q4 8.675 6.3375 6.3375T12 4q3.325-0 5.6625 2.3375T20 12q0 3.325-2.3375 5.6625T12 20Zm0 2q2.075-0 3.9-.7875t3.175-2.1375q1.35-1.35 2.1375-3.175T22 12q-0-2.075-.7875-3.9T19.075 4.925q-1.35-1.35-3.175-2.1375T12 2q-2.075 0-3.9.7875T4.925 4.925Q3.575 6.275 2.7875 8.1T2 12q0 2.075.7875 3.9T4.925 19.075q1.35 1.35 3.175 2.1375T12 22Zm0-8.5q.625 0 1.0625-.4375T13.5 12t-.4375-1.0625T12 10.5t-1.0625.4375T10.5 12t.4375 1.0625T12 13.5Zm-4.5 3 2-7 7-2-2 7-7 2Z"), + EXTENSION("M8.8 21H5Q4.175 21 3.5875 20.4125T3 19V15.2Q4.2 15.2 5.1 14.4375T6 12.5Q6 11.325 5.1 10.5625T3 9.8V6Q3 5.175 3.5875 4.5875T5 4H9Q9 2.95 9.725 2.225T11.5 1.5Q12.55 1.5 13.275 2.225T14 4H18Q18.825 4 19.4125 4.5875T20 6V10Q21.05 10 21.775 10.725T22.5 12.5Q22.5 13.55 21.775 14.275T20 15V19Q20 19.825 19.4125 20.4125T18 21H14.2Q14.2 19.75 13.4125 18.875T11.5 18Q10.375 18 9.5875 18.875T8.8 21ZM5 19H7.125Q7.725 17.35 9.05 16.675T11.5 16Q12.625 16 13.95 16.675T15.875 19H18V13H20Q20.2 13 20.35 12.85T20.5 12.5Q20.5 12.3 20.35 12.15T20 12H18V6H12V4Q12 3.8 11.85 3.65T11.5 3.5Q11.3 3.5 11.15 3.65T11 4V6H5V8.2Q6.35 8.7 7.175 9.875T8 12.5Q8 13.925 7.175 15.1T5 16.8V19ZM11.5 12.5Z"), + FEEDBACK("M12 15Q12.425 15 12.7125 14.7125T13 14Q13 13.575 12.7125 13.2875T12 13Q11.575 13 11.2875 13.2875T11 14Q11 14.425 11.2875 14.7125T12 15ZM11 11H13V5H11V11ZM2 22V4Q2 3.175 2.5875 2.5875T4 2H20Q20.825 2 21.4125 2.5875T22 4V16Q22 16.825 21.4125 17.4125T20 18H6L2 22ZM5.15 16H20V4H4V17.125L5.15 16ZM4 16V4 16Z"), + FOLDER("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8V18Q22 18.825 21.4125 19.4125T20 20H4ZM4 18H20V8H11.175L9.175 6H4V18ZM4 18V6 18Z"), + FOLDER_COPY("M3 21Q2.175 21 1.5875 20.4125T1 19V6H3V19H20V21H3ZM7 17Q6.175 17 5.5875 16.4125T5 15V4Q5 3.175 5.5875 2.5875T7 2H12L14 4H21Q21.825 4 22.4125 4.5875T23 6V15Q23 15.825 22.4125 16.4125T21 17H7ZM7 15H21V6H13.175L11.175 4H7V15ZM7 15V4 15Z"), + FOLDER_OPEN("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8H11.175L9.175 6H4V18L6.4 10H23.5L20.925 18.575Q20.725 19.225 20.1875 19.6125T19 20H4ZM6.1 18H19L20.8 12H7.9L6.1 18ZM6.1 18 7.9 12 6.1 18ZM4 8V6 8Z"), + FORMAT_LIST_BULLETED("M9 19V17H21V19H9ZM9 13V11H21V13H9ZM9 7V5H21V7H9ZM5 20Q4.175 20 3.5875 19.4125T3 18Q3 17.175 3.5875 16.5875T5 16Q5.825 16 6.4125 16.5875T7 18Q7 18.825 6.4125 19.4125T5 20ZM5 14Q4.175 14 3.5875 13.4125T3 12Q3 11.175 3.5875 10.5875T5 10Q5.825 10 6.4125 10.5875T7 12Q7 12.825 6.4125 13.4125T5 14ZM5 8Q4.175 8 3.5875 7.4125T3 6Q3 5.175 3.5875 4.5875T5 4Q5.825 4 6.4125 4.5875T7 6Q7 6.825 6.4125 7.4125T5 8Z"), + FORT("M1 21V17l2-2V9L1 7V3H3V5H5V3H7V5H9V3h2V7L9 9v1h6V9L13 7V3h2V5h2V3h2V5h2V3h2V7L21 9v6l2 2v4H14V18q0-.825-.5875-1.4125T12 16q-.825 0-1.4125.5875T10 18v3H1Zm2-2H8V18q0-1.65 1.175-2.825T12 14q1.65 0 2.825 1.175T16 18v1h5V17.825l-2-2V8.175L20.175 7h-4.35L17 8.175V12H7V8.175L8.175 7H3.825L5 8.175v7.65l-2 2V19Zm9-6Z"), + FOR_YOU("M12 12Q14.025 12 16.225 11.5875T20 10.5V20.5Q18.5 21.175 16.35 21.5875T12 22Q9.8 22 7.65 21.5875T4 20.5V10.5Q5.575 11.175 7.775 11.5875T12 12ZM18 19V13.25Q16.75 13.6 15.1125 13.8T12 14Q10.525 14 8.8875 13.8T6 13.25V19Q7.25 19.45 8.875 19.725T12 20Q13.5 20 15.125 19.725T18 19ZM12 2Q13.65 2 14.825 3.175T16 6Q16 7.65 14.825 8.825T12 10Q10.35 10 9.175 8.825T8 6Q8 4.35 9.175 3.175T12 2ZM12 8Q12.825 8 13.4125 7.4125T14 6Q14 5.175 13.4125 4.5875T12 4Q11.175 4 10.5875 4.5875T10 6Q10 6.825 10.5875 7.4125T12 8ZM12 6ZM12 16.625Z"), + GAMEPAD("M12 7.65ZM16.35 12ZM7.65 12ZM12 16.35ZM12 10.5 9 7.5V2H15V7.5L12 10.5ZM16.5 15 13.5 12 16.5 9H22V15H16.5ZM2 15V9H7.5L10.5 12 7.5 15H2ZM9 22V16.5L12 13.5 15 16.5V22H9ZM12 7.65 13 6.65V4H11V6.65L12 7.65ZM4 13H6.65L7.65 12 6.65 11H4V13ZM11 20H13V17.35L12 16.35 11 17.35V20ZM17.35 13H20V11H17.35L16.35 12 17.35 13Z"), + GLOBE_BOOK("M3.075 13Q3.05 12.75 3.0375 12.5T3.025 12Q3.025 10.125 3.725 8.4875T5.65 5.6375Q6.875 4.425 8.5 3.7125T12 3Q13.875 3 15.5125 3.7125T18.3625 5.6375Q19.575 6.85 20.2875 8.4875T21 12Q21 12.25 20.9875 12.5T20.95 13H18.925Q18.975 12.75 18.9875 12.5T19 12Q19 11.75 18.9875 11.5T18.925 11H15.975Q16 11.25 16 11.5V12.5Q16 12.75 15.975 13H14V12.175Q14 11.875 13.9875 11.575T13.95 11H10.075Q10.05 11.275 10.0375 11.575T10.025 12.175V13H8.05Q8.025 12.75 8.025 12.5V11.5Q8.025 11.25 8.05 11H5.1Q5.05 11.25 5.0375 11.5T5.025 12Q5.025 12.25 5.0375 12.5T5.1 13H3.075ZM5.7 9H8.275Q8.475 7.925 8.775 7.0625T9.425 5.5Q8.225 5.95 7.25 6.8625T5.7 9ZM10.35 9H13.65Q13.4 7.925 13.025 6.9T12 5Q11.35 5.875 10.9625 6.9T10.35 9ZM15.75 9H18.325Q17.75 7.775 16.7625 6.8625T14.575 5.5Q14.925 6.25 15.2375 7.0875T15.75 9ZM11 21V20Q11 18.75 10.125 17.875T8 17H2V15H8Q9.2 15 10.2375 15.525T12 17Q12.725 16.05 13.7625 15.525T16 15H22V17H16Q14.75 17 13.875 17.875T13 20V21H11Z"), + HELP("M11.95 18Q12.475 18 12.8375 17.6375T13.2 16.75Q13.2 16.225 12.8375 15.8625T11.95 15.5Q11.425 15.5 11.0625 15.8625T10.7 16.75Q10.7 17.275 11.0625 17.6375T11.95 18ZM11.05 14.15H12.9Q12.9 13.325 13.0875 12.85T14.15 11.55Q14.8 10.9 15.175 10.3125T15.55 8.9Q15.55 7.5 14.525 6.75T12.1 6Q10.675 6 9.7875 6.75T8.55 8.55L10.2 9.2Q10.325 8.75 10.7625 8.225T12.1 7.7Q12.9 7.7 13.3 8.1375T13.7 9.1Q13.7 9.6 13.4 10.0375T12.65 10.85Q11.55 11.825 11.3 12.325T11.05 14.15ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"), + HOME("M6 19H9V13H15V19H18V10L12 5.5 6 10V19ZM4 21V9L12 3 20 9V21H13V15H11V21H4ZM12 12.25Z"), + HOST("M4 21Q3.175 21 2.5875 20.4125T2 19V5Q2 4.175 2.5875 3.5875T4 3H9Q9.825 3 10.4125 3.5875T11 5V19Q11 19.825 10.4125 20.4125T9 21H4ZM15 21Q14.175 21 13.5875 20.4125T13 19V5Q13 4.175 13.5875 3.5875T15 3H20Q20.825 3 21.4125 3.5875T22 5V19Q22 19.825 21.4125 20.4125T20 21H15ZM4 19H9V5H4V19ZM15 19H20V5H15V19ZM5 15H8V13H5V15ZM16 15H19V13H16V15ZM5 12H8V10H5V12ZM16 12H19V10H16V12ZM5 9H8V7H5V9ZM16 9H19V7H16V9ZM4 19H9 4ZM15 19H20 15Z"), + INFO("M11 17H13V11H11V17ZM12 9Q12.425 9 12.7125 8.7125T13 8Q13 7.575 12.7125 7.2875T12 7Q11.575 7 11.2875 7.2875T11 8Q11 8.425 11.2875 8.7125T12 9ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"), + KEYBOARD_ARROW_DOWN("M12 15.4 6 9.4 7.4 8 12 12.6 16.6 8 18 9.4 12 15.4Z"), + KEYBOARD_ARROW_UP("M12 10.8 7.4 15.4 6 14 12 8 18 14 16.6 15.4 12 10.8Z"), + LANDSCAPE("M1 18l6-8 4.5 6H19L14 9.35l-2.5 3.3L10.25 11 14 6l9 12H1Zm13.025-2ZM5 16H9L7 13.325 5 16ZH9 5Z"), + LIST("M7 9V7H21V9H7ZM7 13V11H21V13H7ZM7 17V15H21V17H7ZM4 9Q3.575 9 3.2875 8.7125T3 8Q3 7.575 3.2875 7.2875T4 7Q4.425 7 4.7125 7.2875T5 8Q5 8.425 4.7125 8.7125T4 9ZM4 13Q3.575 13 3.2875 12.7125T3 12Q3 11.575 3.2875 11.2875T4 11Q4.425 11 4.7125 11.2875T5 12Q5 12.425 4.7125 12.7125T4 13ZM4 17Q3.575 17 3.2875 16.7125T3 16Q3 15.575 3.2875 15.2875T4 15Q4.425 15 4.7125 15.2875T5 16Q5 16.425 4.7125 16.7125T4 17Z"), + LISTS("M2 20V16H6V20H2ZM8 20V16H22V20H8ZM2 14V10H6V14H2ZM8 14V10H22V14H8ZM2 8V4H6V8H2ZM8 8V4H22V8H8Z"), + LOCAL_CAFE("M4 21V19H20V21H4ZM8 17Q6.35 17 5.175 15.825T4 13V3H20Q20.825 3 21.4125 3.5875T22 5V8Q22 8.825 21.4125 9.4125T20 10H18V13Q18 14.65 16.825 15.825T14 17H8ZM8 15H14Q14.825 15 15.4125 14.4125T16 13V5H6V13Q6 13.825 6.5875 14.4125T8 15ZM18 8H20V5H18V8ZM8 15H6 16 8Z"), + LOCATION_CITY("M3 21V7H9V5l3-3 3 3v6h6V21H3Zm2-2H7V17H5v2Zm0-4H7V13H5v2Zm0-4H7V9H5v2Zm6 8h2V17H11v2Zm0-4h2V13H11v2Zm0-4h2V9H11v2Zm0-4h2V5H11V7Zm6 12h2V17H17v2Zm0-4h2V13H17v2Z"), + MENU("M3 18V16H21V18H3ZM3 13V11H21V13H3ZM3 8V6H21V8H3Z"), + MICROSOFT("M4 20H22v2H4V13H20v7h2V4H20v7H4V4h7V20h2V4h9V2H2V22H4"), // Not Material + MINIMIZE("M6 21V19H18V21H6Z"), + MOJANG("M13.9658 0C12.9552.828 12.7686 2.195 12.7007 3.418 12.7219 4.4201 14.0423 4.7174 14.6538 4.0082 15.0912 2.6579 14.3692 1.2739 13.9658 0ZM10.913 2.9297C10.7559 3.6983 10.6284 4.4669 10.4925 5.2355 8.9894 3.9913 7.1505 3.0825 5.142 3.2948 3.4944 3.4646.9429 2.6961.1404 4.6452-.1229 8.314.0722 12.0124.034 15.6896-.0891 16.9126.8957 18.1101 2.1781 17.9699 6.3989 18.0039 10.6283 18.0172 14.8491 17.9661 16.1145 18.102 16.8067 16.9554 16.9851 15.8768 13.2696 16.4374 9.1761 16.9296 5.7111 15.1292 2.5986 13.5836 2.246 8.3139 5.5581 6.798 9.3203 5.1759 13.8596 8.0383 14.9424 11.7877 15.6133 12.1105 16.2844 12.4247 16.9596 12.7432 16.6241 10.3483 16.6537 7.8005 15.5793 5.5882 15.0571 4.7474 14.0975 6.2714 13.3799 5.6217 12.416 4.8659 11.7495 3.8129 10.913 2.9297Z"), // Not Material + MORE_HORIZ("M6 14Q5.175 14 4.5875 13.4125T4 12Q4 11.175 4.5875 10.5875T6 10Q6.825 10 7.4125 10.5875T8 12Q8 12.825 7.4125 13.4125T6 14ZM12 14Q11.175 14 10.5875 13.4125T10 12Q10 11.175 10.5875 10.5875T12 10Q12.825 10 13.4125 10.5875T14 12Q14 12.825 13.4125 13.4125T12 14ZM18 14Q17.175 14 16.5875 13.4125T16 12Q16 11.175 16.5875 10.5875T18 10Q18.825 10 19.4125 10.5875T20 12Q20 12.825 19.4125 13.4125T18 14Z"), + MORE_VERT("M12 20Q11.175 20 10.5875 19.4125T10 18Q10 17.175 10.5875 16.5875T12 16Q12.825 16 13.4125 16.5875T14 18Q14 18.825 13.4125 19.4125T12 20ZM12 14Q11.175 14 10.5875 13.4125T10 12Q10 11.175 10.5875 10.5875T12 10Q12.825 10 13.4125 10.5875T14 12Q14 12.825 13.4125 13.4125T12 14ZM12 8Q11.175 8 10.5875 7.4125T10 6Q10 5.175 10.5875 4.5875T12 4Q12.825 4 13.4125 4.5875T14 6Q14 6.825 13.4125 7.4125T12 8Z"), + OPEN_IN_NEW("M5 21Q4.175 21 3.5875 20.4125T3 19V5Q3 4.175 3.5875 3.5875T5 3H12V5H5V19H19V12H21V19Q21 19.825 20.4125 20.4125T19 21H5ZM9.7 15.7 8.3 14.3 17.6 5H14V3H21V10H19V6.4L9.7 15.7Z"), + OUTPUT("M5 21Q4.175 21 3.5875 20.4125T3 19V5Q3 4.175 3.5875 3.5875T5 3H19Q19.825 3 20.4125 3.5875T21 5V7H19V5H5V19H19V17H21V19Q21 19.825 20.4125 20.4125T19 21H5ZM17 17 15.6 15.6 18.175 13H9V11H18.175L15.6 8.4 17 7 22 12 17 17Z"), + PACKAGE("M10 9.75 12 8.75 14 9.75V5H10V9.75ZM7 17V15H12V17H7ZM5 21Q4.175 21 3.5875 20.4125T3 19V5Q3 4.175 3.5875 3.5875T5 3H19Q19.825 3 20.4125 3.5875T21 5V19Q21 19.825 20.4125 20.4125T19 21H5ZM5 5V19 5ZM5 19H19V5H16V13L12 11 8 13V5H5V19Z"), + PACKAGE2("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM11 21.725 4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725ZM16 8.525 17.925 7.425 12 4 10.05 5.125 16 8.525ZM12 10.85 13.95 9.725 8.025 6.3 6.075 7.425 12 10.85Z"), + PERSON("M12 12Q10.35 12 9.175 10.825T8 8Q8 6.35 9.175 5.175T12 4Q13.65 4 14.825 5.175T16 8Q16 9.65 14.825 10.825T12 12ZM4 20V17.2Q4 16.35 4.4375 15.6375T5.6 14.55Q7.15 13.775 8.75 13.3875T12 13Q13.65 13 15.25 13.3875T18.4 14.55Q19.125 14.925 19.5625 15.6375T20 17.2V20H4ZM6 18H18V17.2Q18 16.925 17.8625 16.7T17.5 16.35Q16.15 15.675 14.775 15.3375T12 15Q10.6 15 9.225 15.3375T6.5 16.35Q6.275 16.475 6.1375 16.7T6 17.2V18ZM12 10Q12.825 10 13.4125 9.4125T14 8Q14 7.175 13.4125 6.5875T12 6Q11.175 6 10.5875 6.5875T10 8Q10 8.825 10.5875 9.4125T12 10ZM12 8ZM12 18Z"), + PUBLIC("M12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM11 19.95V18Q10.175 18 9.5875 17.4125T9 16V15L4.2 10.2Q4.125 10.65 4.0625 11.1T4 12Q4 15.025 5.9875 17.3T11 19.95ZM17.9 17.4Q18.925 16.275 19.4625 14.8875T20 12Q20 9.55 18.6375 7.525T15 4.6V5Q15 5.825 14.4125 6.4125T13 7H11V9Q11 9.425 10.7125 9.7125T10 10H8V12H14Q14.425 12 14.7125 12.2875T15 13V16H16Q16.65 16 17.175 16.3875T17.9 17.4Z"), + REFRESH("M12 20Q8.65 20 6.325 17.675T4 12Q4 8.65 6.325 6.325T12 4Q13.725 4 15.3 4.7125T18 6.75V4H20V11H13V9H17.2Q16.4 7.6 15.0125 6.8T12 6Q9.5 6 7.75 7.75T6 12Q6 14.5 7.75 16.25T12 18Q13.925 18 15.475 16.9T17.65 14H19.75Q19.05 16.65 16.9 18.325T12 20Z"), + RELEASE_CIRCLE("M9,7H13A2,2 0 0,1 15,9V11C15,11.84 14.5,12.55 13.76,12.85L15,17H13L11.8,13H11V17H9V7M11,9V11H13V9H11M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,16.41 7.58,20 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z"), // Not Material + RESTORE("M12 21Q8.55 21 5.9875 18.7125T3.05 13H5.1Q5.45 15.6 7.4125 17.3T12 19Q14.925 19 16.9625 16.9625T19 12Q19 9.075 16.9625 7.0375T12 5Q10.275 5 8.775 5.8T6.25 8H9V10H3V4H5V6.35Q6.275 4.75 8.1125 3.875T12 3Q13.875 3 15.5125 3.7125T18.3625 5.6375Q19.575 6.85 20.2875 8.4875T21 12Q21 13.875 20.2875 15.5125T18.3625 18.3625Q17.15 19.575 15.5125 20.2875T12 21Z"), // Not Material + ROCKET_LAUNCH("M5.65 10.025 7.6 10.85Q7.95 10.15 8.325 9.5T9.15 8.2L7.75 7.925 5.65 10.025ZM9.2 12.1 12.05 14.925Q13.1 14.525 14.3 13.7T16.55 11.825Q18.3 10.075 19.2875 7.9375T20.15 4Q18.35 3.875 16.2 4.8625T12.3 7.6Q11.25 8.65 10.425 9.85T9.2 12.1ZM13.65 10.475Q13.075 9.9 13.075 9.0625T13.65 7.65Q14.225 7.075 15.075 7.075T16.5 7.65Q17.075 8.225 17.075 9.0625T16.5 10.475Q15.925 11.05 15.075 11.05T13.65 10.475ZM14.125 18.5 16.225 16.4 15.95 15Q15.3 15.45 14.65 15.8125T13.3 16.525L14.125 18.5ZM21.95 2.175Q22.425 5.2 21.3625 8.0625T17.7 13.525L18.2 16Q18.3 16.5 18.15 16.975T17.65 17.8L13.45 22 11.35 17.075 7.075 12.8 2.15 10.7 6.325 6.5Q6.675 6.15 7.1625 6T8.15 5.95L10.625 6.45Q13.225 3.85 16.075 2.775T21.95 2.175ZM3.925 15.975Q4.8 15.1 6.0625 15.0875T8.2 15.95Q9.075 16.825 9.0625 18.0875T8.175 20.225Q7.55 20.85 6.0875 21.3T2.05 22.1Q2.4 19.525 2.85 18.0625T3.925 15.975ZM5.35 17.375Q5.1 17.625 4.85 18.2875T4.5 19.625Q5.175 19.525 5.8375 19.2875T6.75 18.8Q7.05 18.5 7.075 18.075T6.8 17.35Q6.5 17.05 6.075 17.0625T5.35 17.375Z"), + SCHEMA("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Zm2-2H9V19H6v2Zm0-8H9V11H6v2Zm10 0h3V11H16v2ZM6 5H9V3H6V5ZM7.5 4Zm0 8Zm10 0Zm-10 8Z"), + SCREENSHOT_MONITOR("M15 16H19V12H17.5V14.5H15V16ZM5 10H6.5V7.5H9V6H5V10ZM8 21V19H4Q3.175 19 2.5875 18.4125T2 17V5Q2 4.175 2.5875 3.5875T4 3H20Q20.825 3 21.4125 3.5875T22 5V17Q22 17.825 21.4125 18.4125T20 19H16V21H8ZM4 17H20V5H4V17ZM4 17V5 17Z"), + SCRIPT("M14,20A2,2 0 0,0 16,18V5H9A1,1 0 0,0 8,6V16H5V5A3,3 0 0,1 8,2H19A3,3 0 0,1 22,5V6H18V18L18,19A3,3 0 0,1 15,22H5A3,3 0 0,1 2,19V18H12A2,2 0 0,0 14,20Z"), // Not Material + SEARCH("M19.6 21 13.3 14.7Q12.55 15.3 11.575 15.65T9.5 16Q6.775 16 4.8875 14.1125T3 9.5Q3 6.775 4.8875 4.8875T9.5 3Q12.225 3 14.1125 4.8875T16 9.5Q16 10.6 15.65 11.575T14.7 13.3L21 19.6 19.6 21ZM9.5 14Q11.375 14 12.6875 12.6875T14 9.5Q14 7.625 12.6875 6.3125T9.5 5Q7.625 5 6.3125 6.3125T5 9.5Q5 11.375 6.3125 12.6875T9.5 14Z"), + SELECT_ALL("M7 17V7H17V17H7ZM9 15H15V9H9V15ZM5 19V21Q4.175 21 3.5875 20.4125T3 19H5ZM3 17V15H5V17H3ZM3 13V11H5V13H3ZM3 9V7H5V9H3ZM5 5H3Q3 4.175 3.5875 3.5875T5 3V5ZM7 21V19H9V21H7ZM7 5V3H9V5H7ZM11 21V19H13V21H11ZM11 5V3H13V5H11ZM15 21V19H17V21H15ZM15 5V3H17V5H15ZM19 21V19H21Q21 19.825 20.4125 20.4125T19 21ZM19 17V15H21V17H19ZM19 13V11H21V13H19ZM19 9V7H21V9H19ZM19 5V3Q19.825 3 20.4125 3.5875T21 5H19Z"), + SETTINGS("M19.43 12.98C19.47 12.66 19.5 12.34 19.5 12 19.5 11.66 19.47 11.34 19.43 11.02L21.54 9.37C21.73 9.22 21.78 8.95 21.66 8.73L19.66 5.27C19.57 5.11 19.4 5.02 19.22 5.02 19.16 5.02 19.1 5.03 19.05 5.05L16.56 6.05C16.04 5.65 15.48 5.32 14.87 5.07L14.49 2.42C14.46 2.18 14.25 2 14 2H10C9.75 2 9.54 2.18 9.51 2.42L9.13 5.07C8.52 5.32 7.96 5.66 7.44 6.05L4.95 5.05C4.89 5.03 4.83 5.02 4.77 5.02 4.6 5.02 4.43 5.11 4.34 5.27L2.34 8.73C2.21 8.95 2.27 9.22 2.46 9.37L4.57 11.02C4.53 11.34 4.5 11.67 4.5 12 4.5 12.33 4.53 12.66 4.57 12.98L2.46 14.63C2.27 14.78 2.22 15.05 2.34 15.27L4.34 18.73C4.43 18.89 4.6 18.98 4.78 18.98 4.84 18.98 4.9 18.97 4.95 18.95L7.44 17.95C7.96 18.35 8.52 18.68 9.13 18.93L9.51 21.58C9.54 21.82 9.75 22 10 22H14C14.25 22 14.46 21.82 14.49 21.58L14.87 18.93C15.48 18.68 16.04 18.34 16.56 17.95L19.05 18.95C19.11 18.97 19.17 18.98 19.23 18.98 19.4 18.98 19.57 18.89 19.66 18.73L21.66 15.27C21.78 15.05 21.73 14.78 21.54 14.63L19.43 12.98ZM17.45 11.27C17.49 11.58 17.5 11.79 17.5 12 17.5 12.21 17.48 12.43 17.45 12.73L17.31 13.86 18.2 14.56 19.28 15.4 18.58 16.61 17.31 16.1 16.27 15.68 15.37 16.36C14.94 16.68 14.53 16.92 14.12 17.09L13.06 17.52 12.9 18.65 12.7 20H11.3L11.11 18.65 10.95 17.52 9.89 17.09C9.46 16.91 9.06 16.68 8.66 16.38L7.75 15.68 6.69 16.11 5.42 16.62 4.72 15.41 5.8 14.57 6.69 13.87 6.55 12.74C6.52 12.43 6.5 12.2 6.5 12S6.52 11.57 6.55 11.27L6.69 10.14 5.8 9.44 4.72 8.6 5.42 7.39 6.69 7.9 7.73 8.32 8.63 7.64C9.06 7.32 9.47 7.08 9.88 6.91L10.94 6.48 11.1 5.35 11.3 4H12.69L12.88 5.35 13.04 6.48 14.1 6.91C14.53 7.09 14.93 7.32 15.33 7.62L16.24 8.32 17.3 7.89 18.57 7.38 19.27 8.59 18.2 9.44 17.31 10.14 17.45 11.27ZM12 8C9.79 8 8 9.79 8 12S9.79 16 12 16 16 14.21 16 12 14.21 8 12 8ZM12 14C10.9 14 10 13.1 10 12S10.9 10 12 10 14 10.9 14 12 13.1 14 12 14Z"), // Material Icons + STADIA_CONTROLLER("M4.725 20Q3.225 20 2.1625 18.925T1.05 16.325Q1.05 16.1 1.075 15.875T1.15 15.425L3.25 7.025Q3.6 5.675 4.675 4.8375T7.125 4H16.875Q18.25 4 19.325 4.8375T20.75 7.025L22.85 15.425Q22.9 15.65 22.9375 15.8875T22.975 16.35Q22.975 17.875 21.8875 18.9375T19.275 20Q18.225 20 17.325 19.45T15.975 17.95L15.275 16.5Q15.15 16.25 14.9 16.125T14.375 16H9.625Q9.35 16 9.1 16.125T8.725 16.5L8.025 17.95Q7.575 18.9 6.675 19.45T4.725 20ZM4.8 18Q5.275 18 5.6625 17.75T6.25 17.075L6.95 15.65Q7.325 14.875 8.05 14.4375T9.625 14H14.375Q15.225 14 15.95 14.45T17.075 15.65L17.775 17.075Q17.975 17.5 18.3625 17.75T19.225 18Q19.925 18 20.425 17.5375T20.95 16.375Q20.95 16.4 20.9 15.9L18.8 7.525Q18.625 6.85 18.1 6.425T16.875 6H7.125Q6.425 6 5.8875 6.425T5.2 7.525L3.1 15.9Q3.05 16.05 3.05 16.35 3.05 17.05 3.5625 17.525T4.8 18ZM13.5 11Q13.925 11 14.2125 10.7125T14.5 10Q14.5 9.575 14.2125 9.2875T13.5 9Q13.075 9 12.7875 9.2875T12.5 10Q12.5 10.425 12.7875 10.7125T13.5 11ZM15.5 9Q15.925 9 16.2125 8.7125T16.5 8Q16.5 7.575 16.2125 7.2875T15.5 7Q15.075 7 14.7875 7.2875T14.5 8Q14.5 8.425 14.7875 8.7125T15.5 9ZM15.5 13Q15.925 13 16.2125 12.7125T16.5 12Q16.5 11.575 16.2125 11.2875T15.5 11Q15.075 11 14.7875 11.2875T14.5 12Q14.5 12.425 14.7875 12.7125T15.5 13ZM17.5 11Q17.925 11 18.2125 10.7125T18.5 10Q18.5 9.575 18.2125 9.2875T17.5 9Q17.075 9 16.7875 9.2875T16.5 10Q16.5 10.425 16.7875 10.7125T17.5 11ZM8.5 12.5Q8.825 12.5 9.0375 12.2875T9.25 11.75V10.75H10.25Q10.575 10.75 10.7875 10.5375T11 10Q11 9.675 10.7875 9.4625T10.25 9.25H9.25V8.25Q9.25 7.925 9.0375 7.7125T8.5 7.5Q8.175 7.5 7.9625 7.7125T7.75 8.25V9.25H6.75Q6.425 9.25 6.2125 9.4625T6 10Q6 10.325 6.2125 10.5375T6.75 10.75H7.75V11.75Q7.75 12.075 7.9625 12.2875T8.5 12.5ZM12 12Z"), + STYLE("M3.975 19.8 3.125 19.45Q2.35 19.125 2.0875 18.325T2.175 16.75L3.975 12.85V19.8ZM7.975 22Q7.15 22 6.5625 21.4125T5.975 20V14L8.625 21.35Q8.7 21.525 8.775 21.6875T8.975 22H7.975ZM13.125 21.9Q12.325 22.2 11.575 21.825T10.525 20.65L6.075 8.45Q5.775 7.65 6.125 6.8875T7.275 5.85L14.825 3.1Q15.625 2.8 16.375 3.175T17.425 4.35L21.875 16.55Q22.175 17.35 21.825 18.1125T20.675 19.15L13.125 21.9ZM10.975 10Q11.4 10 11.6875 9.7125T11.975 9Q11.975 8.575 11.6875 8.2875T10.975 8Q10.55 8 10.2625 8.2875T9.975 9Q9.975 9.425 10.2625 9.7125T10.975 10ZM12.425 20 19.975 17.25 15.525 5 7.975 7.75 12.425 20ZM7.975 7.75 15.525 5 7.975 7.75Z"), + TEXTURE("M4.4-3Q3.925-3.1 3.5125-3.5125T3-4.4L19.6-21Q20.125-20.875 20.5-20.4875T21.025-19.6L4.4-3ZM3-9.3V-12.1L11.9-21H14.7L3-9.3ZM3-17V-19Q3-19.825 3.5875-20.4125T5-21H7L3-17ZM17-3 21-7V-5Q21-4.175 20.4125-3.5875T19-3H17ZM9.3-3 21-14.7V-11.9L12.1-3H9.3Z"), + TRIP("M4 21Q3.175 21 2.5875 20.4125T2 19V8Q2 7.175 2.5875 6.5875T4 6H8V4Q8 3.175 8.5875 2.5875T10 2H14Q14.825 2 15.4125 2.5875T16 4V6H20Q20.825 6 21.4125 6.5875T22 8V19Q22 19.825 21.4125 20.4125T20 21H4ZM10 6H14V4H10V6ZM6 8H4V19H6V8ZM16 19V8H8V19H16ZM18 8V19H20V8H18ZM12 13.5Z"), + TUNE("M11 21V15H13V17H21V19H13V21H11ZM3 19V17H9V19H3ZM7 15V13H3V11H7V9H9V15H7ZM11 13V11H21V13H11ZM15 9V3H17V5H21V7H17V9H15ZM3 7V5H13V7H3Z"), + UPDATE("M12 21Q10.125 21 8.4875 20.2875T5.6375 18.3625Q4.425 17.15 3.7125 15.5125T3 12Q3 10.125 3.7125 8.4875T5.6375 5.6375Q6.85 4.425 8.4875 3.7125T12 3Q14.05 3 15.8875 3.875T19 6.35V4H21V10H15V8H17.75Q16.725 6.6 15.225 5.8T12 5Q9.075 5 7.0375 7.0375T5 12Q5 14.925 7.0375 16.9625T12 19Q14.625 19 16.5875 17.3T18.9 13H20.95Q20.575 16.425 18.0125 18.7125T12 21ZM14.8 16.2 11 12.4V7H13V11.6L16.2 14.8 14.8 16.2Z"), + VISIBILITY("M12 16q1.875 0 3.1875-1.3125T16.5 11.5 15.1875 8.3125 12 7 8.8125 8.3125 7.5 11.5t1.3125 3.1875T12 16Zm0-1.8q-1.125 0-1.9125-.7875T9.3 11.5t.7875-1.9125T12 8.8q1.125 0 1.9125.7875T14.7 11.5q0 1.125-.7875 1.9125T12 14.2ZM12 19q-3.65 0-6.65-2.0375T1 11.5Q2.35 8.075 5.35 6.0375T12 4q3.65 0 6.65 2.0375T23 11.5q-1.35 3.425-4.35 5.4625T12 19Zm0-7.5ZM12 17q2.825 0 5.1875-1.4875T20.8 11.5q-1.25-2.525-3.6125-4.0125T12 6 6.8125 7.4875 3.2 11.5q1.25 2.525 3.6125 4.0125T12 17Z"), + VISIBILITY_OFF("M16.1 13.3l-1.45-1.45q.225-1.175-.675-2.2t-2.325-.8L10.2 7.4q.425-.2.8625-.3T12 7q1.875 0 3.1875 1.3125T16.5 11.5q0 .5-.1.9375t-.3.8625Zm3.2 3.15-1.45-1.4q.95-.725 1.6875-1.5875T20.8 11.5q-1.25-2.525-3.5875-4.0125T12 6q-.725 0-1.425.1T9.2 6.4L7.65 4.85q1.025-.425 2.1-.6375T12 4q3.775 0 6.725 2.0875T23 11.5q-.575 1.475-1.5125 2.7375T19.3 16.45Zm.5 6.15-4.2-4.15q-.875.275-1.7625.4125T12 19q-3.775 0-6.725-2.0875T1 11.5q.525-1.325 1.325-2.4625T4.15 7L1.4 4.2 2.8 2.8 21.2 21.2l-1.4 1.4ZM5.55 8.4q-.725.65-1.325 1.425T3.2 11.5q1.25 2.525 3.5875 4.0125T12 17q.5 0 .975-.0625T13.95 16.8l-.9-.95q-.275.075-.525.1125T12 16q-1.875 0-3.1875-1.3125T7.5 11.5q0-.275.0375-.525T7.65 10.45L5.55 8.4Zm7.975 2.325ZM9.75 12.6Z"), + WARNING("M1 21 12 2 23 21H1ZM4.45 19H19.55L12 6 4.45 19ZM12 18Q12.425 18 12.7125 17.7125T13 17Q13 16.575 12.7125 16.2875T12 16Q11.575 16 11.2875 16.2875T11 17Q11 17.425 11.2875 17.7125T12 18ZM11 15H13V10H11V15ZM12 12.5Z"), + WB_SUNNY("M11 4V1H13V4H11ZM11 23V20H13V23H11ZM20 13V11H23V13H20ZM1 13V11H4V13H1ZM18.7 6.7 17.3 5.3 19.05 3.5 20.5 4.95 18.7 6.7ZM4.95 20.5 3.5 19.05 5.3 17.3 6.7 18.7 4.95 20.5ZM19.05 20.5 17.3 18.7 18.7 17.3 20.5 19.05 19.05 20.5ZM5.3 6.7 3.5 4.95 4.95 3.5 6.7 5.3 5.3 6.7ZM12 18Q9.5 18 7.75 16.25T6 12Q6 9.5 7.75 7.75T12 6Q14.5 6 16.25 7.75T18 12Q18 14.5 16.25 16.25T12 18ZM12 16Q13.675 16 14.8375 14.8375T16 12Q16 10.325 14.8375 9.1625T12 8Q10.325 8 9.1625 9.1625T8 12Q8 13.675 9.1625 14.8375T12 16ZM12 12Z"), + ; + + public static final double DEFAULT_SIZE = 24; + + private final String path; + + SVG(String path) { + this.path = path; + } + + public String getPath() { + return path; + } + + private static Node createIcon(SVGPath path, double size) { + if (size < 0) { StackPane pane = new StackPane(path); pane.setAlignment(Pos.CENTER); return pane; } - Group svg = new Group(path); - double scale = Math.min(width / svg.getBoundsInParent().getWidth(), height / svg.getBoundsInParent().getHeight()); - svg.setScaleX(scale); - svg.setScaleY(scale); - - return svg; - } - - // default fill: white, width: 20, height 20 - - public static Node gear(ObjectBinding fill, double width, double height) { - return createSVGPath("M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z", fill, width, height); - } - - public static Node back(ObjectBinding fill, double width, double height) { - return createSVGPath("M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z", fill, width, height); - } - - public static Node close(ObjectBinding fill, double width, double height) { - return createSVGPath("M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", fill, width, height); - } - - public static Node dotsVertical(ObjectBinding fill, double width, double height) { - return createSVGPath("M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z", fill, width, height); + double scale = size / 24; + path.setScaleX(scale); + path.setScaleY(scale); + return new Group(path); } - public static Node delete(ObjectBinding fill, double width, double height) { - return createSVGPath("M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z", fill, width, height); - } - - public static Node accountEdit(ObjectBinding fill, double width, double height) { - return createSVGPath("M21.7,13.35L20.7,14.35L18.65,12.3L19.65,11.3C19.86,11.09 20.21,11.09 20.42,11.3L21.7,12.58C21.91,12.79 21.91,13.14 21.7,13.35M12,18.94L18.06,12.88L20.11,14.93L14.06,21H12V18.94M12,14C7.58,14 4,15.79 4,18V20H10V18.11L14,14.11C13.34,14.03 12.67,14 12,14M12,4A4,4 0 0,0 8,8A4,4 0 0,0 12,12A4,4 0 0,0 16,8A4,4 0 0,0 12,4Z", fill, width, height); - } - - public static Node expand(ObjectBinding fill, double width, double height) { - return createSVGPath("M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z", fill, width, height); - } - - public static Node collapse(ObjectBinding fill, double width, double height) { - return createSVGPath("M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z", fill, width, height); - } + public Node createIcon(ObservableValue fill, double size) { + SVGPath p = new SVGPath(); + p.getStyleClass().add("svg"); + p.setContent(path); + if (fill != null) + p.fillProperty().bind(fill); - public static Node navigate(ObjectBinding fill, double width, double height) { - return createSVGPath("M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z", fill, width, height); + return createIcon(p, size); } - public static Node launch(ObjectBinding fill, double width, double height) { - return createSVGPath("M1008 6.286q18.857 13.714 15.429 36.571l-146.286 877.714q-2.857 16.571-18.286 25.714-8 4.571-17.714 4.571-6.286 0-13.714-2.857l-258.857-105.714-138.286 168.571q-10.286 13.143-28 13.143-7.429 0-12.571-2.286-10.857-4-17.429-13.429t-6.571-20.857v-199.429l493.714-605.143-610.857 528.571-225.714-92.571q-21.143-8-22.857-31.429-1.143-22.857 18.286-33.714l950.857-548.571q8.571-5.143 18.286-5.14311.429 0 20.571 6.286z", fill, width, height); - } - - public static Node launch2(ObjectBinding fill, double width, double height) { - return createSVGPath("M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z", fill, width, height); - } - - public static Node script(ObjectBinding fill, double width, double height) { - return createSVGPath("M14,20A2,2 0 0,0 16,18V5H9A1,1 0 0,0 8,6V16H5V5A3,3 0 0,1 8,2H19A3,3 0 0,1 22,5V6H18V18L18,19A3,3 0 0,1 15,22H5A3,3 0 0,1 2,19V18H12A2,2 0 0,0 14,20Z", fill, width, height); - } - - public static Node pencil(ObjectBinding fill, double width, double height) { - return createSVGPath("M20.71,4.04C21.1,3.65 21.1,3 20.71,2.63L18.37,0.29C18,-0.1 17.35,-0.1 16.96,0.29L15,2.25L18.75,6M17.75,7L14,3.25L4,13.25V17H7.75L17.75,7Z", fill, width, height); - } + public Node createIcon(Paint fill, double size) { + SVGPath p = new SVGPath(); + p.getStyleClass().add("svg"); + p.setContent(path); + if (fill != null) + p.fillProperty().set(fill); - public static Node refresh(ObjectBinding fill, double width, double height) { - return createSVGPath("M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z", fill, width, height); + return createIcon(p, size); } - public static Node folderOpen(ObjectBinding fill, double width, double height) { - return createSVGPath("M19,20H4C2.89,20 2,19.1 2,18V6C2,4.89 2.89,4 4,4H10L12,6H19A2,2 0 0,1 21,8H21L4,8V18L6.14,10H23.21L20.93,18.5C20.7,19.37 19.92,20 19,20Z", fill, width, height); - } - - public static Node update(ObjectBinding fill, double width, double height) { - return createSVGPath("M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2C4.35,9.91 4.35,14.28 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.12,16.65 18.36,18.39C14.85,21.87 9.15,21.87 5.64,18.39C2.14,14.92 2.11,9.28 5.62,5.81C9.13,2.34 14.76,2.34 18.27,5.81L21,3V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z", fill, width, height); - } - - public static Node closeCircle(ObjectBinding fill, double width, double height) { - return createSVGPath("M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z", fill, width, height); - } - - public static Node checkCircle(ObjectBinding fill, double width, double height) { - return createSVGPath("M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M11,16.5L18,9.5L16.59,8.09L11,13.67L7.91,10.59L6.5,12L11,16.5Z", fill, width, height); - } - - public static Node infoCircle(ObjectBinding fill, double width, double height) { - return createSVGPath("M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z", fill, width, height); - } - - public static Node helpCircle(ObjectBinding fill, double width, double height) { - return createSVGPath("M15.07,11.25L14.17,12.17C13.45,12.89 13,13.5 13,15H11V14.5C11,13.39 11.45,12.39 12.17,11.67L13.41,10.41C13.78,10.05 14,9.55 14,9C14,7.89 13.1,7 12,7A2,2 0 0,0 10,9H8A4,4 0 0,1 12,5A4,4 0 0,1 16,9C16,9.88 15.64,10.67 15.07,11.25M13,19H11V17H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z", fill, width, height); - } - - public static Node alert(ObjectBinding fill, double width, double height) { - return createSVGPath("M13,14H11V10H13M13,18H11V16H13M1,21H23L12,2L1,21Z", fill, width, height); - } - - public static Node plus(ObjectBinding fill, double width, double height) { - return createSVGPath("M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z", fill, width, height); - } - - public static Node importIcon(ObjectBinding fill, double width, double height) { - return createSVGPath("M14,12L10,8V11H2V13H10V16M20,18V6C20,4.89 19.1,4 18,4H6A2,2 0 0,0 4,6V9H6V6H18V18H6V15H4V18A2,2 0 0,0 6,20H18A2,2 0 0,0 20,18Z", fill, width, height); - } - - public static Node export(ObjectBinding fill, double width, double height) { - return createSVGPath("M23,12L19,8V11H10V13H19V16M1,18V6C1,4.89 1.9,4 3,4H15A2,2 0 0,1 17,6V9H15V6H3V18H15V15H17V18A2,2 0 0,1 15,20H3A2,2 0 0,1 1,18Z", fill, width, height); - } - - public static Node openInNew(ObjectBinding fill, double width, double height) { - return createSVGPath("M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z", fill, width, height); - } - - public static Node triangle(ObjectBinding fill, double width, double height) { - return createSVGPath("M1,21H23L12,2", fill, width, height); - } - - public static Node home(ObjectBinding fill, double width, double height) { - return createSVGPath("M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z", fill, width, height); - } - - public static Node viewList(ObjectBinding fill, double width, double height) { - return createSVGPath("M7,5H21V7H7V5M7,13V11H21V13H7M4,4.5A1.5,1.5 0 0,1 5.5,6A1.5,1.5 0 0,1 4,7.5A1.5,1.5 0 0,1 2.5,6A1.5,1.5 0 0,1 4,4.5M4,10.5A1.5,1.5 0 0,1 5.5,12A1.5,1.5 0 0,1 4,13.5A1.5,1.5 0 0,1 2.5,12A1.5,1.5 0 0,1 4,10.5M7,19V17H21V19H7M4,16.5A1.5,1.5 0 0,1 5.5,18A1.5,1.5 0 0,1 4,19.5A1.5,1.5 0 0,1 2.5,18A1.5,1.5 0 0,1 4,16.5Z", fill, width, height); - } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java new file mode 100644 index 0000000000..268bf50b17 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java @@ -0,0 +1,210 @@ +// Copy from https://github.com/palexdev/MaterialFX/blob/c8038ce2090f5cddf923a19d79cc601db86a4d17/materialfx/src/main/java/io/github/palexdev/materialfx/utils/ScrollUtils.java + +/* + * Copyright (C) 2022 Parisi Alessandro + * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX). + * + * MaterialFX is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MaterialFX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with MaterialFX. If not, see . + */ + +package org.jackhuang.hmcl.ui; + +import javafx.animation.Animation; +import javafx.animation.Animation.Status; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.event.EventHandler; +import javafx.scene.control.ScrollPane; +import javafx.scene.input.MouseEvent; +import javafx.scene.input.ScrollEvent; +import javafx.util.Duration; +import org.jackhuang.hmcl.util.Holder; + +/** + * Utility class for ScrollPanes. + */ +final class ScrollUtils { + + public enum ScrollDirection { + UP(-1), RIGHT(-1), DOWN(1), LEFT(1); + + final int intDirection; + + ScrollDirection(int intDirection) { + this.intDirection = intDirection; + } + + public int intDirection() { + return intDirection; + } + } + + private ScrollUtils() { + } + + /** + * Determines if the given ScrollEvent comes from a trackpad. + *

+ * Although this method works in most cases, it is not very accurate. + * Since in JavaFX there's no way to tell if a ScrollEvent comes from a trackpad or a mouse + * we use this trick: I noticed that a mouse scroll has a delta of 32 (don't know if it changes depending on the device or OS) + * and trackpad scrolls have a way smaller delta. So depending on the scroll direction we check if the delta is lesser than 10 + * (trackpad event) or greater(mouse event). + * + * @see ScrollEvent#getDeltaX() + * @see ScrollEvent#getDeltaY() + */ + public static boolean isTrackPad(ScrollEvent event, ScrollDirection scrollDirection) { + switch (scrollDirection) { + case UP: + case DOWN: + return Math.abs(event.getDeltaY()) < 10; + case LEFT: + case RIGHT: + return Math.abs(event.getDeltaX()) < 10; + default: + return false; + } + } + + /** + * Determines the scroll direction of the given ScrollEvent. + *

+ * Although this method works fine, it is not very accurate. + * In JavaFX there's no concept of scroll direction, if you try to scroll with a trackpad + * you'll notice that you can scroll in both directions at the same time, both deltaX and deltaY won't be 0. + *

+ * For this method to work we assume that this behavior is not possible. + *

+ * If deltaY is 0 we return LEFT or RIGHT depending on deltaX (respectively if lesser or greater than 0). + *

+ * Else we return DOWN or UP depending on deltaY (respectively if lesser or greater than 0). + * + * @see ScrollEvent#getDeltaX() + * @see ScrollEvent#getDeltaY() + */ + public static ScrollDirection determineScrollDirection(ScrollEvent event) { + double deltaX = event.getDeltaX(); + double deltaY = event.getDeltaY(); + + if (deltaY == 0.0) { + return deltaX < 0 ? ScrollDirection.LEFT : ScrollDirection.RIGHT; + } else { + return deltaY < 0 ? ScrollDirection.DOWN : ScrollDirection.UP; + } + } + + //================================================================================ + // ScrollPanes + //================================================================================ + + /** + * Adds a smooth scrolling effect to the given scroll pane, + * calls {@link #addSmoothScrolling(ScrollPane, double)} with a + * default speed value of 1. + */ + public static void addSmoothScrolling(ScrollPane scrollPane) { + addSmoothScrolling(scrollPane, 1); + } + + /** + * Adds a smooth scrolling effect to the given scroll pane with the given scroll speed. + * Calls {@link #addSmoothScrolling(ScrollPane, double, double)} + * with a default trackPadAdjustment of 7. + */ + public static void addSmoothScrolling(ScrollPane scrollPane, double speed) { + addSmoothScrolling(scrollPane, speed, 7); + } + + /** + * Adds a smooth scrolling effect to the given scroll pane with the given + * scroll speed and the given trackPadAdjustment. + *

+ * The trackPadAdjustment is a value used to slow down the scrolling if a trackpad is used. + * This is kind of a workaround and it's not perfect, but at least it's way better than before. + * The default value is 7, tested up to 10, further values can cause scrolling misbehavior. + */ + public static void addSmoothScrolling(ScrollPane scrollPane, double speed, double trackPadAdjustment) { + smoothScroll(scrollPane, speed, trackPadAdjustment); + } + + private static final double[] FRICTIONS = {0.99, 0.1, 0.05, 0.04, 0.03, 0.02, 0.01, 0.04, 0.01, 0.008, 0.008, 0.008, 0.008, 0.0006, 0.0005, 0.00003, 0.00001}; + private static final Duration DURATION = Duration.millis(3); + + private static void smoothScroll(ScrollPane scrollPane, double speed, double trackPadAdjustment) { + final double[] derivatives = new double[FRICTIONS.length]; + + Timeline timeline = new Timeline(); + Holder scrollDirectionHolder = new Holder<>(); + final EventHandler mouseHandler = event -> timeline.stop(); + final EventHandler scrollHandler = event -> { + if (event.getEventType() == ScrollEvent.SCROLL) { + ScrollDirection scrollDirection = determineScrollDirection(event); + scrollDirectionHolder.value = scrollDirection; + + double currentSpeed = isTrackPad(event, scrollDirection) ? speed / trackPadAdjustment : speed; + + derivatives[0] += scrollDirection.intDirection * currentSpeed; + if (timeline.getStatus() == Status.STOPPED) { + timeline.play(); + } + event.consume(); + } + }; + if (scrollPane.getContent().getParent() != null) { + scrollPane.getContent().getParent().addEventHandler(MouseEvent.MOUSE_PRESSED, mouseHandler); + scrollPane.getContent().getParent().addEventHandler(ScrollEvent.ANY, scrollHandler); + } + scrollPane.getContent().parentProperty().addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.removeEventHandler(MouseEvent.MOUSE_PRESSED, mouseHandler); + oldValue.removeEventHandler(ScrollEvent.ANY, scrollHandler); + } + if (newValue != null) { + newValue.addEventHandler(MouseEvent.MOUSE_PRESSED, mouseHandler); + newValue.addEventHandler(ScrollEvent.ANY, scrollHandler); + } + }); + + timeline.getKeyFrames().add(new KeyFrame(DURATION, event -> { + for (int i = 0; i < derivatives.length; i++) { + derivatives[i] *= FRICTIONS[i]; + } + for (int i = 1; i < derivatives.length; i++) { + derivatives[i] += derivatives[i - 1]; + } + + double dy = derivatives[derivatives.length - 1]; + double size; + switch (scrollDirectionHolder.value) { + case LEFT: + case RIGHT: + size = scrollPane.getContent().getLayoutBounds().getWidth(); + scrollPane.setHvalue(Math.min(Math.max(scrollPane.getHvalue() + dy / size, 0), 1)); + break; + case UP: + case DOWN: + size = scrollPane.getContent().getLayoutBounds().getHeight(); + scrollPane.setVvalue(Math.min(Math.max(scrollPane.getVvalue() + dy / size, 0), 1)); + break; + } + + if (Math.abs(dy) < 0.001) { + timeline.stop(); + } + })); + timeline.setCycleCount(Animation.INDEFINITE); + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SettingsPage.java deleted file mode 100644 index 75bf402b58..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SettingsPage.java +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui; - -import com.jfoenix.controls.JFXColorPicker; -import com.jfoenix.effects.JFXDepthManager; -import javafx.application.Platform; -import javafx.beans.InvalidationListener; -import javafx.beans.WeakInvalidationListener; -import javafx.beans.binding.Bindings; -import javafx.beans.binding.When; -import javafx.beans.property.ReadOnlyStringProperty; -import javafx.beans.property.ReadOnlyStringWrapper; -import javafx.scene.control.ToggleGroup; -import javafx.scene.paint.Color; -import javafx.scene.text.Font; -import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.setting.*; -import org.jackhuang.hmcl.ui.construct.MessageBox; -import org.jackhuang.hmcl.ui.construct.Validator; -import org.jackhuang.hmcl.ui.decorator.DecoratorPage; -import org.jackhuang.hmcl.upgrade.RemoteVersion; -import org.jackhuang.hmcl.upgrade.UpdateChannel; -import org.jackhuang.hmcl.upgrade.UpdateChecker; -import org.jackhuang.hmcl.upgrade.UpdateHandler; -import org.jackhuang.hmcl.util.Logging; -import org.jackhuang.hmcl.util.i18n.Locales; -import org.jackhuang.hmcl.util.javafx.SafeStringConverter; - -import java.awt.*; -import java.io.IOException; -import java.net.Proxy; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Arrays; -import java.util.Collections; -import java.util.Optional; -import java.util.logging.Level; - -import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.util.Lang.thread; -import static org.jackhuang.hmcl.util.Logging.LOG; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.reservedSelectedPropertyFor; -import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor; - -public final class SettingsPage extends SettingsView implements DecoratorPage { - private final ReadOnlyStringWrapper title = new ReadOnlyStringWrapper(this, "title", i18n("settings.launcher")); - - private InvalidationListener updateListener; - - public SettingsPage() { - FXUtils.smoothScrolling(scroll); - - // ==== Download sources ==== - cboDownloadSource.getItems().setAll(DownloadProviders.providersById.keySet()); - selectedItemPropertyFor(cboDownloadSource).bindBidirectional(config().downloadTypeProperty()); - // ==== - - // ==== Font ==== - cboFont.valueProperty().bindBidirectional(config().fontFamilyProperty()); - - txtFontSize.textProperty().bindBidirectional(config().fontSizeProperty(), - SafeStringConverter.fromFiniteDouble() - .restrict(it -> it > 0) - .fallbackTo(12.0) - .asPredicate(Validator.addTo(txtFontSize))); - - lblDisplay.fontProperty().bind(Bindings.createObjectBinding( - () -> Font.font(config().getFontFamily(), config().getFontSize()), - config().fontFamilyProperty(), config().fontSizeProperty())); - // ==== - - // ==== Languages ==== - cboLanguage.getItems().setAll(Locales.LOCALES); - selectedItemPropertyFor(cboLanguage).bindBidirectional(config().localizationProperty()); - // ==== - - // ==== Proxy ==== - txtProxyHost.textProperty().bindBidirectional(config().proxyHostProperty()); - txtProxyPort.textProperty().bindBidirectional(config().proxyPortProperty(), - SafeStringConverter.fromInteger() - .restrict(it -> it >= 0 && it <= 0xFFFF) - .fallbackTo(0) - .asPredicate(Validator.addTo(txtProxyPort))); - txtProxyUsername.textProperty().bindBidirectional(config().proxyUserProperty()); - txtProxyPassword.textProperty().bindBidirectional(config().proxyPassProperty()); - - proxyPane.disableProperty().bind(chkDisableProxy.selectedProperty()); - authPane.disableProperty().bind(chkProxyAuthentication.selectedProperty().not()); - - reservedSelectedPropertyFor(chkDisableProxy).bindBidirectional(config().hasProxyProperty()); - chkProxyAuthentication.selectedProperty().bindBidirectional(config().hasProxyAuthProperty()); - - ToggleGroup proxyConfigurationGroup = new ToggleGroup(); - chkProxyHttp.setUserData(Proxy.Type.HTTP); - chkProxyHttp.setToggleGroup(proxyConfigurationGroup); - chkProxySocks.setUserData(Proxy.Type.SOCKS); - chkProxySocks.setToggleGroup(proxyConfigurationGroup); - selectedItemPropertyFor(proxyConfigurationGroup, Proxy.Type.class).bindBidirectional(config().proxyTypeProperty()); - // ==== - - fileCommonLocation.loadChildren(Arrays.asList( - fileCommonLocation.createChildren(i18n("launcher.cache_directory.default"), EnumCommonDirectory.DEFAULT) - ), EnumCommonDirectory.CUSTOM); - fileCommonLocation.selectedDataProperty().bindBidirectional(config().commonDirTypeProperty()); - fileCommonLocation.customTextProperty().bindBidirectional(config().commonDirectoryProperty()); - fileCommonLocation.subtitleProperty().bind( - Bindings.createObjectBinding(() -> Optional.ofNullable(Settings.instance().getCommonDirectory()) - .orElse(i18n("launcher.cache_directory.disabled")), - config().commonDirectoryProperty(), config().commonDirTypeProperty())); - - // ==== Update ==== - FXUtils.installFastTooltip(btnUpdate, i18n("update.tooltip")); - updateListener = any -> { - btnUpdate.setVisible(UpdateChecker.isOutdated()); - - if (UpdateChecker.isOutdated()) { - lblUpdateSub.setText(i18n("update.newest_version", UpdateChecker.getLatestVersion().getVersion())); - lblUpdateSub.getStyleClass().setAll("update-label"); - - lblUpdate.setText(i18n("update.found")); - lblUpdate.getStyleClass().setAll("update-label"); - } else if (UpdateChecker.isCheckingUpdate()) { - lblUpdateSub.setText(i18n("update.checking")); - lblUpdateSub.getStyleClass().setAll("subtitle-label"); - - lblUpdate.setText(i18n("update")); - lblUpdate.getStyleClass().setAll(); - } else { - lblUpdateSub.setText(i18n("update.latest")); - lblUpdateSub.getStyleClass().setAll("subtitle-label"); - - lblUpdate.setText(i18n("update")); - lblUpdate.getStyleClass().setAll(); - } - }; - UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener)); - UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener)); - UpdateChecker.checkingUpdateProperty().addListener(new WeakInvalidationListener(updateListener)); - updateListener.invalidated(null); - - lblUpdateNote.setWrappingWidth(470); - - ToggleGroup updateChannelGroup = new ToggleGroup(); - chkUpdateDev.setToggleGroup(updateChannelGroup); - chkUpdateDev.setUserData(UpdateChannel.DEVELOPMENT); - chkUpdateStable.setToggleGroup(updateChannelGroup); - chkUpdateStable.setUserData(UpdateChannel.STABLE); - selectedItemPropertyFor(updateChannelGroup, UpdateChannel.class).bindBidirectional(config().updateChannelProperty()); - // ==== - - // ==== Background ==== - backgroundItem.loadChildren(Collections.singletonList( - backgroundItem.createChildren(i18n("launcher.background.default"), EnumBackgroundImage.DEFAULT) - ), EnumBackgroundImage.CUSTOM); - backgroundItem.customTextProperty().bindBidirectional(config().backgroundImageProperty()); - backgroundItem.selectedDataProperty().bindBidirectional(config().backgroundImageTypeProperty()); - backgroundItem.subtitleProperty().bind( - new When(backgroundItem.selectedDataProperty().isEqualTo(EnumBackgroundImage.DEFAULT)) - .then(i18n("launcher.background.default")) - .otherwise(config().backgroundImageProperty())); - // ==== - - // ==== Theme ==== - JFXColorPicker picker = new JFXColorPicker(Color.web(config().getTheme().getColor()), null); - picker.setCustomColorText(i18n("color.custom")); - picker.setRecentColorsText(i18n("color.recent")); - picker.getCustomColors().setAll(Theme.SUGGESTED_COLORS); - picker.setOnAction(e -> { - Theme theme = Theme.custom(Theme.getColorDisplayName(picker.getValue())); - config().setTheme(theme); - Controllers.getScene().getStylesheets().setAll(theme.getStylesheets()); - }); - themeColorPickerContainer.getChildren().setAll(picker); - Platform.runLater(() -> JFXDepthManager.setDepth(picker, 0)); - // ==== - } - - public String getTitle() { - return title.get(); - } - - @Override - public ReadOnlyStringProperty titleProperty() { - return title.getReadOnlyProperty(); - } - - public void setTitle(String title) { - this.title.set(title); - } - - @Override - protected void onUpdate() { - RemoteVersion target = UpdateChecker.getLatestVersion(); - if (target == null) { - return; - } - UpdateHandler.updateFrom(target); - } - - @Override - protected void onExportLogs() { - // We cannot determine which file is JUL using. - // So we write all the logs to a new file. - thread(() -> { - Path logFile = Paths.get("hmcl-exported-logs-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".log").toAbsolutePath(); - - LOG.info("Exporting logs to " + logFile); - try { - Files.write(logFile, Logging.getRawLogs()); - } catch (IOException e) { - Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.failed") + "\n" + e, null, MessageBox.ERROR_MESSAGE)); - LOG.log(Level.WARNING, "Failed to export logs", e); - return; - } - - Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", logFile))); - if (Desktop.isDesktopSupported()) { - try { - Desktop.getDesktop().open(logFile.toFile()); - } catch (IOException ignored) { - } - } - }); - } - - @Override - protected void onHelp() { - FXUtils.openLink(Metadata.HELP_URL); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SettingsView.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SettingsView.java deleted file mode 100644 index 07638486c5..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SettingsView.java +++ /dev/null @@ -1,494 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui; - -import com.jfoenix.controls.*; -import javafx.geometry.HPos; -import javafx.geometry.Pos; -import javafx.geometry.VPos; -import javafx.scene.control.Label; -import javafx.scene.control.ScrollPane; -import javafx.scene.layout.*; -import javafx.scene.text.Text; -import javafx.scene.text.TextAlignment; -import org.jackhuang.hmcl.setting.EnumBackgroundImage; -import org.jackhuang.hmcl.setting.EnumCommonDirectory; -import org.jackhuang.hmcl.setting.Theme; -import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale; - -import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -public abstract class SettingsView extends StackPane { - protected final JFXTextField txtProxyHost; - protected final JFXTextField txtProxyPort; - protected final JFXTextField txtProxyUsername; - protected final JFXPasswordField txtProxyPassword; - protected final JFXTextField txtFontSize; - protected final JFXComboBox cboLanguage; - protected final JFXComboBox cboDownloadSource; - protected final FontComboBox cboFont; - protected final MultiFileItem fileCommonLocation; - protected final Label lblDisplay; - protected final Label lblUpdate; - protected final Label lblUpdateSub; - protected final Text lblUpdateNote; - protected final JFXRadioButton chkUpdateStable; - protected final JFXRadioButton chkUpdateDev; - protected final JFXButton btnUpdate; - protected final ScrollPane scroll; - protected final MultiFileItem backgroundItem; - protected final StackPane themeColorPickerContainer; - protected final JFXCheckBox chkDisableProxy; - protected final JFXRadioButton chkProxyHttp; - protected final JFXRadioButton chkProxySocks; - protected final JFXCheckBox chkProxyAuthentication; - protected final GridPane authPane; - protected final Pane proxyPane; - - public SettingsView() { - scroll = new ScrollPane(); - getChildren().setAll(scroll); - scroll.setStyle("-fx-font-size: 14;"); - scroll.setFitToWidth(true); - - { - VBox rootPane = new VBox(); - rootPane.setStyle("-fx-padding: 18;"); - { - ComponentList settingsPane = new ComponentList(); - { - ComponentSublist updatePane = new ComponentSublist(); - updatePane.setTitle(i18n("update")); - updatePane.setHasSubtitle(true); - { - VBox headerLeft = new VBox(); - - lblUpdate = new Label(i18n("update")); - lblUpdateSub = new Label(); - lblUpdateSub.getStyleClass().setAll("subtitle-label"); - - headerLeft.getChildren().setAll(lblUpdate, lblUpdateSub); - updatePane.setHeaderLeft(headerLeft); - } - - { - btnUpdate = new JFXButton(); - btnUpdate.setOnMouseClicked(e -> onUpdate()); - btnUpdate.getStyleClass().setAll("toggle-icon4"); - btnUpdate.setGraphic(SVG.update(Theme.blackFillBinding(), 20, 20)); - - updatePane.setHeaderRight(btnUpdate); - } - - { - VBox content = new VBox(); - content.setSpacing(8); - - chkUpdateStable = new JFXRadioButton(i18n("update.channel.stable")); - chkUpdateDev = new JFXRadioButton(i18n("update.channel.dev")); - - VBox noteWrapper = new VBox(); - noteWrapper.setStyle("-fx-padding: 10 0 0 0;"); - lblUpdateNote = new Text(i18n("update.note")); - noteWrapper.getChildren().setAll(lblUpdateNote); - - content.getChildren().setAll(chkUpdateStable, chkUpdateDev, noteWrapper); - - updatePane.getContent().add(content); - } - settingsPane.getContent().add(updatePane); - } - - { - BorderPane updatePane = new BorderPane(); - { - VBox headerLeft = new VBox(); - - Label help = new Label(i18n("help")); - Label helpSubtitle = new Label(i18n("help.detail")); - helpSubtitle.getStyleClass().setAll("subtitle-label"); - - headerLeft.getChildren().setAll(help, helpSubtitle); - updatePane.setLeft(headerLeft); - } - - { - JFXButton btnExternal = new JFXButton(); - btnExternal.setOnMouseClicked(e -> onHelp()); - btnExternal.getStyleClass().setAll("toggle-icon4"); - btnExternal.setGraphic(SVG.openInNew(Theme.blackFillBinding(), -1, -1)); - - updatePane.setRight(btnExternal); - } - settingsPane.getContent().add(updatePane); - } - - { - fileCommonLocation = new MultiFileItem<>(true); - fileCommonLocation.setTitle(i18n("launcher.cache_directory")); - fileCommonLocation.setDirectory(true); - fileCommonLocation.setChooserTitle(i18n("launcher.cache_directory.choose")); - fileCommonLocation.setHasSubtitle(true); - fileCommonLocation.setCustomText("settings.custom"); - - settingsPane.getContent().add(fileCommonLocation); - } - - { - backgroundItem = new MultiFileItem<>(true); - backgroundItem.setTitle(i18n("launcher.background")); - backgroundItem.setChooserTitle(i18n("launcher.background.choose")); - backgroundItem.setHasSubtitle(true); - backgroundItem.setCustomText(i18n("settings.custom")); - - settingsPane.getContent().add(backgroundItem); - } - - { - BorderPane downloadSourcePane = new BorderPane(); - { - Label label = new Label(i18n("settings.launcher.download_source")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - downloadSourcePane.setLeft(label); - } - - { - cboDownloadSource = new JFXComboBox<>(); - cboDownloadSource.setConverter(stringConverter(key -> i18n("download.provider." + key))); - downloadSourcePane.setRight(cboDownloadSource); - } - settingsPane.getContent().add(downloadSourcePane); - } - - { - BorderPane languagePane = new BorderPane(); - - Label left = new Label(i18n("settings.launcher.language")); - BorderPane.setAlignment(left, Pos.CENTER_LEFT); - languagePane.setLeft(left); - - cboLanguage = new JFXComboBox<>(); - cboLanguage.setConverter(stringConverter(locale -> locale.getName(config().getLocalization().getResourceBundle()))); - FXUtils.setLimitWidth(cboLanguage, 400); - languagePane.setRight(cboLanguage); - - settingsPane.getContent().add(languagePane); - } - - { - ComponentList proxyList = new ComponentList(); - proxyList.setTitle(i18n("settings.launcher.proxy")); - - VBox proxyWrapper = new VBox(); - proxyWrapper.setSpacing(10); - - { - chkDisableProxy = new JFXCheckBox(i18n("settings.launcher.proxy.disable")); - proxyWrapper.getChildren().add(chkDisableProxy); - } - - { - proxyPane = new VBox(); - proxyPane.setStyle("-fx-padding: 0 0 0 30;"); - - ColumnConstraints colHgrow = new ColumnConstraints(); - colHgrow.setHgrow(Priority.ALWAYS); - - { - HBox hBox = new HBox(); - chkProxyHttp = new JFXRadioButton(i18n("settings.launcher.proxy.http")); - chkProxySocks = new JFXRadioButton(i18n("settings.launcher.proxy.socks")); - hBox.getChildren().setAll(chkProxyHttp, chkProxySocks); - proxyPane.getChildren().add(hBox); - } - - { - GridPane gridPane = new GridPane(); - gridPane.setHgap(20); - gridPane.setVgap(10); - gridPane.setStyle("-fx-padding: 0 0 0 15;"); - gridPane.getColumnConstraints().setAll(new ColumnConstraints(), colHgrow); - gridPane.getRowConstraints().setAll(new RowConstraints(), new RowConstraints()); - - { - Label host = new Label(i18n("settings.launcher.proxy.host")); - GridPane.setRowIndex(host, 1); - GridPane.setColumnIndex(host, 0); - GridPane.setHalignment(host, HPos.RIGHT); - gridPane.getChildren().add(host); - } - - { - txtProxyHost = new JFXTextField(); - GridPane.setRowIndex(txtProxyHost, 1); - GridPane.setColumnIndex(txtProxyHost, 1); - gridPane.getChildren().add(txtProxyHost); - } - - { - Label port = new Label(i18n("settings.launcher.proxy.port")); - GridPane.setRowIndex(port, 2); - GridPane.setColumnIndex(port, 0); - GridPane.setHalignment(port, HPos.RIGHT); - gridPane.getChildren().add(port); - } - - { - txtProxyPort = new JFXTextField(); - GridPane.setRowIndex(txtProxyPort, 2); - GridPane.setColumnIndex(txtProxyPort, 1); - FXUtils.setValidateWhileTextChanged(txtProxyPort, true); - txtProxyHost.getValidators().setAll(new NumberValidator(i18n("input.number"), false)); - gridPane.getChildren().add(txtProxyPort); - } - proxyPane.getChildren().add(gridPane); - } - - { - VBox vBox = new VBox(); - vBox.setStyle("-fx-padding: 20 0 20 5;"); - - chkProxyAuthentication = new JFXCheckBox(i18n("settings.launcher.proxy.authentication")); - vBox.getChildren().setAll(chkProxyAuthentication); - - proxyPane.getChildren().add(vBox); - } - - { - authPane = new GridPane(); - authPane.setHgap(20); - authPane.setVgap(10); - authPane.setStyle("-fx-padding: 0 0 0 15;"); - authPane.getColumnConstraints().setAll(new ColumnConstraints(), colHgrow); - authPane.getRowConstraints().setAll(new RowConstraints(), new RowConstraints()); - - { - Label username = new Label(i18n("settings.launcher.proxy.username")); - GridPane.setRowIndex(username, 0); - GridPane.setColumnIndex(username, 0); - authPane.getChildren().add(username); - } - - { - txtProxyUsername = new JFXTextField(); - GridPane.setRowIndex(txtProxyUsername, 0); - GridPane.setColumnIndex(txtProxyUsername, 1); - authPane.getChildren().add(txtProxyUsername); - } - - { - Label password = new Label(i18n("settings.launcher.proxy.password")); - GridPane.setRowIndex(password, 1); - GridPane.setColumnIndex(password, 0); - authPane.getChildren().add(password); - } - - { - txtProxyPassword = new JFXPasswordField(); - GridPane.setRowIndex(txtProxyPassword, 1); - GridPane.setColumnIndex(txtProxyPassword, 1); - authPane.getChildren().add(txtProxyPassword); - } - - proxyPane.getChildren().add(authPane); - } - proxyWrapper.getChildren().add(proxyPane); - } - proxyList.getContent().add(proxyWrapper); - settingsPane.getContent().add(proxyList); - } - - { - BorderPane themePane = new BorderPane(); - - Label left = new Label(i18n("settings.launcher.theme")); - BorderPane.setAlignment(left, Pos.CENTER_LEFT); - themePane.setLeft(left); - - themeColorPickerContainer = new StackPane(); - themeColorPickerContainer.setMinHeight(30); - themePane.setRight(themeColorPickerContainer); - - settingsPane.getContent().add(themePane); - } - - { - ComponentSublist logPane = new ComponentSublist(); - logPane.setTitle(i18n("settings.launcher.log")); - - { - JFXButton logButton = new JFXButton(i18n("settings.launcher.launcher_log.export")); - logButton.setOnMouseClicked(e -> onExportLogs()); - logButton.getStyleClass().setAll("jfx-button-border"); - - logPane.setHeaderRight(logButton); - } - - { - VBox fontPane = new VBox(); - fontPane.setSpacing(5); - - { - BorderPane borderPane = new BorderPane(); - fontPane.getChildren().add(borderPane); - { - Label left = new Label(i18n("settings.launcher.log.font")); - BorderPane.setAlignment(left, Pos.CENTER_LEFT); - borderPane.setLeft(left); - } - - { - HBox hBox = new HBox(); - hBox.setSpacing(3); - - cboFont = new FontComboBox(12); - txtFontSize = new JFXTextField(); - FXUtils.setLimitWidth(txtFontSize, 50); - hBox.getChildren().setAll(cboFont, txtFontSize); - - borderPane.setRight(hBox); - } - } - - lblDisplay = new Label("[23:33:33] [Client Thread/INFO] [WaterPower]: Loaded mod WaterPower."); - fontPane.getChildren().add(lblDisplay); - - logPane.getContent().add(fontPane); - } - settingsPane.getContent().add(logPane); - } - - { - StackPane aboutPane = new StackPane(); - GridPane gridPane = new GridPane(); - gridPane.setHgap(20); - gridPane.setVgap(10); - - ColumnConstraints col1 = new ColumnConstraints(); - col1.setHgrow(Priority.SOMETIMES); - col1.setMaxWidth(Double.NEGATIVE_INFINITY); - col1.setMinWidth(Double.NEGATIVE_INFINITY); - - ColumnConstraints col2 = new ColumnConstraints(); - col2.setHgrow(Priority.SOMETIMES); - col2.setMinWidth(20); - col2.setMaxWidth(Double.POSITIVE_INFINITY); - - gridPane.getColumnConstraints().setAll(col1, col2); - - RowConstraints row = new RowConstraints(); - row.setMinHeight(Double.NEGATIVE_INFINITY); - row.setValignment(VPos.TOP); - row.setVgrow(Priority.SOMETIMES); - gridPane.getRowConstraints().setAll(row, row, row, row, row, row); - - { - Label label = new Label(i18n("about.copyright")); - GridPane.setRowIndex(label, 0); - GridPane.setColumnIndex(label, 0); - gridPane.getChildren().add(label); - } - { - Label label = new Label(i18n("about.copyright.statement")); - label.setWrapText(true); - GridPane.setRowIndex(label, 0); - GridPane.setColumnIndex(label, 1); - gridPane.getChildren().add(label); - } - { - Label label = new Label(i18n("about.author")); - GridPane.setRowIndex(label, 1); - GridPane.setColumnIndex(label, 0); - gridPane.getChildren().add(label); - } - { - Label label = new Label(i18n("about.author.statement")); - label.setWrapText(true); - GridPane.setRowIndex(label, 1); - GridPane.setColumnIndex(label, 1); - gridPane.getChildren().add(label); - } - { - Label label = new Label(i18n("about.thanks_to")); - GridPane.setRowIndex(label, 2); - GridPane.setColumnIndex(label, 0); - gridPane.getChildren().add(label); - } - { - Label label = new Label(i18n("about.thanks_to.statement")); - label.setWrapText(true); - GridPane.setRowIndex(label, 2); - GridPane.setColumnIndex(label, 1); - gridPane.getChildren().add(label); - } - { - Label label = new Label(i18n("about.dependency")); - GridPane.setRowIndex(label, 3); - GridPane.setColumnIndex(label, 0); - gridPane.getChildren().add(label); - } - { - Label label = new Label(i18n("about.dependency.statement")); - label.setWrapText(true); - GridPane.setRowIndex(label, 3); - GridPane.setColumnIndex(label, 1); - gridPane.getChildren().add(label); - } - { - Label label = new Label(i18n("about.claim")); - GridPane.setRowIndex(label, 4); - GridPane.setColumnIndex(label, 0); - gridPane.getChildren().add(label); - } - { - Label label = new Label(i18n("about.claim.statement")); - label.setWrapText(true); - label.setTextAlignment(TextAlignment.JUSTIFY); - GridPane.setRowIndex(label, 4); - GridPane.setColumnIndex(label, 1); - gridPane.getChildren().add(label); - } - { - Label label = new Label(i18n("about.open_source")); - GridPane.setRowIndex(label, 5); - GridPane.setColumnIndex(label, 0); - gridPane.getChildren().add(label); - } - { - Label label = new Label(i18n("about.open_source.statement")); - label.setWrapText(true); - GridPane.setRowIndex(label, 5); - GridPane.setColumnIndex(label, 1); - gridPane.getChildren().add(label); - } - aboutPane.getChildren().setAll(gridPane); - settingsPane.getContent().add(aboutPane); - } - rootPane.getChildren().add(settingsPane); - } - scroll.setContent(rootPane); - } - } - - protected abstract void onUpdate(); - protected abstract void onHelp(); - protected abstract void onExportLogs(); -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin.java new file mode 100644 index 0000000000..f4cb8c6817 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin.java @@ -0,0 +1,118 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui; + +import com.jfoenix.controls.JFXButton; +import javafx.beans.binding.Bindings; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.SkinBase; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.ui.construct.ComponentList; +import org.jackhuang.hmcl.ui.construct.SpinnerPane; + +import java.util.List; + +public abstract class ToolbarListPageSkin> extends SkinBase { + + public ToolbarListPageSkin(T skinnable) { + super(skinnable); + + SpinnerPane spinnerPane = new SpinnerPane(); + spinnerPane.loadingProperty().bind(skinnable.loadingProperty()); + spinnerPane.failedReasonProperty().bind(skinnable.failedReasonProperty()); + spinnerPane.onFailedActionProperty().bind(skinnable.onFailedActionProperty()); + spinnerPane.getStyleClass().add("large-spinner-pane"); + + ComponentList root = new ComponentList(); + root.getStyleClass().add("no-padding"); + StackPane.setMargin(root, new Insets(10)); + + List toolbarButtons = initializeToolbar(skinnable); + if (!toolbarButtons.isEmpty()) { + HBox toolbar = new HBox(); + toolbar.setAlignment(Pos.CENTER_LEFT); + toolbar.setPickOnBounds(false); + toolbar.getChildren().setAll(toolbarButtons); + root.getContent().add(toolbar); + } + + { + ScrollPane scrollPane = new ScrollPane(); + ComponentList.setVgrow(scrollPane, Priority.ALWAYS); + scrollPane.setFitToWidth(true); + + VBox content = new VBox(); + + Bindings.bindContent(content.getChildren(), skinnable.itemsProperty()); + + scrollPane.setContent(content); + FXUtils.smoothScrolling(scrollPane); + + root.getContent().add(scrollPane); + } + + spinnerPane.setContent(root); + + getChildren().setAll(spinnerPane); + } + + public static Node wrap(Node node) { + StackPane stackPane = new StackPane(); + stackPane.setPadding(new Insets(0, 5, 0, 2)); + stackPane.getChildren().setAll(node); + return stackPane; + } + + public static JFXButton createToolbarButton(String text, SVG svg, Runnable onClick) { + JFXButton ret = new JFXButton(); + ret.getStyleClass().add("jfx-tool-bar-button"); + ret.textFillProperty().bind(Theme.foregroundFillBinding()); + ret.setGraphic(wrap(svg.createIcon(Theme.foregroundFillBinding(), -1))); + ret.setText(text); + ret.setOnAction(e -> onClick.run()); + return ret; + } + + public static JFXButton createToolbarButton2(String text, SVG svg, Runnable onClick) { + JFXButton ret = new JFXButton(); + ret.getStyleClass().add("jfx-tool-bar-button"); + ret.setGraphic(wrap(svg.createIcon(Theme.blackFill(), -1))); + ret.setText(text); + ret.setOnAction(e -> onClick.run()); + return ret; + } + + public static JFXButton createDecoratorButton(String tooltip, SVG svg, Runnable onClick) { + JFXButton ret = new JFXButton(); + ret.getStyleClass().add("jfx-decorator-button"); + ret.textFillProperty().bind(Theme.foregroundFillBinding()); + ret.setGraphic(wrap(svg.createIcon(Theme.foregroundFillBinding(), -1))); + FXUtils.installFastTooltip(ret, tooltip); + ret.setOnAction(e -> onClick.run()); + return ret; + } + + protected abstract List initializeToolbar(T skinnable); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/UpgradeDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/UpgradeDialog.java new file mode 100644 index 0000000000..73a48ea643 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/UpgradeDialog.java @@ -0,0 +1,99 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXDialogLayout; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.ScrollPane; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; +import org.jackhuang.hmcl.ui.construct.JFXHyperlink; +import org.jackhuang.hmcl.upgrade.RemoteVersion; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Node; + +import java.io.IOException; +import java.net.URL; + +import static org.jackhuang.hmcl.Metadata.CHANGELOG_URL; +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class UpgradeDialog extends JFXDialogLayout { + public UpgradeDialog(RemoteVersion remoteVersion, Runnable updateRunnable) { + maxWidthProperty().bind(Controllers.getScene().widthProperty().multiply(0.7)); + maxHeightProperty().bind(Controllers.getScene().heightProperty().multiply(0.7)); + + setHeading(new Label(i18n("update.changelog"))); + setBody(new ProgressIndicator()); + + String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html"; + Task.supplyAsync(Schedulers.io(), () -> { + Document document = Jsoup.parse(new URL(url), 30 * 1000); + Node node = document.selectFirst("#nowchange"); + if (node == null || !"h1".equals(node.nodeName())) + throw new IOException("Cannot find #nowchange in document"); + + HTMLRenderer renderer = new HTMLRenderer(uri -> { + LOG.info("Open link: " + uri); + FXUtils.openLink(uri.toString()); + }); + + do { + if ("h1".equals(node.nodeName()) && !"nowchange".equals(node.attr("id"))) { + break; + } + renderer.appendNode(node); + node = node.nextSibling(); + } while (node != null); + + renderer.mergeLineBreaks(); + return renderer.render(); + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + ScrollPane scrollPane = new ScrollPane(result); + scrollPane.setFitToWidth(true); + FXUtils.smoothScrolling(scrollPane); + setBody(scrollPane); + } else { + LOG.warning("Failed to load update log, trying to open it in browser"); + FXUtils.openLink(url); + setBody(); + } + }).start(); + + JFXHyperlink openInBrowser = new JFXHyperlink(i18n("web.view_in_browser")); + openInBrowser.setExternalLink(url); + + JFXButton updateButton = new JFXButton(i18n("update.accept")); + updateButton.getStyleClass().add("dialog-accept"); + updateButton.setOnAction(e -> updateRunnable.run()); + + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.getStyleClass().add("dialog-cancel"); + cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); + + setActions(openInBrowser, updateButton, cancelButton); + onEscPressed(this, cancelButton::fire); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java index 460eb558f5..4a7032d38b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,11 +24,11 @@ import javafx.collections.ListChangeListener; import javafx.collections.WeakListChangeListener; -import java.util.LinkedList; +import java.util.ArrayList; import java.util.List; public class WeakListenerHolder { - private List refs = new LinkedList<>(); + private final List refs = new ArrayList<>(0); public WeakListenerHolder() { } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WebPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WebPage.java new file mode 100644 index 0000000000..2542d698f0 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WebPage.java @@ -0,0 +1,76 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.paint.Color; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.construct.SpinnerPane; +import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/** + * @author Glavo + */ +public final class WebPage extends SpinnerPane implements DecoratorPage { + + private final ObjectProperty stateProperty; + + public WebPage(String title, String content) { + this.stateProperty = new SimpleObjectProperty<>(DecoratorPage.State.fromTitle(title)); + this.setBackground(new Background(new BackgroundFill(Color.WHITE, null, null))); + + Task.supplyAsync(() -> { + Document document = Jsoup.parseBodyFragment(content); + HTMLRenderer renderer = new HTMLRenderer(uri -> { + Controllers.confirm(i18n("web.open_in_browser", uri), i18n("message.confirm"), () -> { + FXUtils.openLink(uri.toString()); + }, null); + }); + renderer.appendNode(document); + renderer.mergeLineBreaks(); + return renderer.render(); + }).whenComplete(Schedulers.javafx(), ((result, exception) -> { + if (exception == null) { + ScrollPane scrollPane = new ScrollPane(); + scrollPane.setFitToWidth(true); + scrollPane.setContent(result); + setContent(scrollPane); + setFailedReason(null); + } else { + LOG.warning("Failed to load content", exception); + setFailedReason(i18n("web.failed")); + } + })).start(); + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + return stateProperty; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WebStage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WebStage.java deleted file mode 100644 index 977a61d142..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WebStage.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui; - -import javafx.scene.Scene; -import javafx.scene.image.Image; -import javafx.scene.web.WebView; -import javafx.stage.Stage; - -import static org.jackhuang.hmcl.setting.ConfigHolder.config; - -public class WebStage extends Stage { - private final WebView webView = new WebView(); - - public WebStage() { - setScene(new Scene(webView, 800, 480)); - getScene().getStylesheets().addAll(config().getTheme().getStylesheets()); - getIcons().add(new Image("/assets/img/icon.png")); - } - - public WebView getWebView() { - return webView; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountAdvancedListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountAdvancedListItem.java index 3aca654680..8c9da5ec13 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountAdvancedListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountAdvancedListItem.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,51 +20,93 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; -import javafx.scene.image.Image; +import javafx.beans.value.ObservableValue; +import javafx.scene.canvas.Canvas; +import javafx.scene.control.Tooltip; import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.auth.offline.OfflineAccount; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; import org.jackhuang.hmcl.game.TexturesLoader; -import org.jackhuang.hmcl.setting.Theme; -import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.AdvancedListItem; +import org.jackhuang.hmcl.util.javafx.BindingMapping; +import static javafx.beans.binding.Bindings.createStringBinding; +import static org.jackhuang.hmcl.setting.Accounts.getAccountFactory; +import static org.jackhuang.hmcl.setting.Accounts.getLocalizedLoginTypeName; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class AccountAdvancedListItem extends AdvancedListItem { - private ObjectProperty account = new SimpleObjectProperty() { + private final Tooltip tooltip; + private final Canvas canvas; + + private final ObjectProperty account = new SimpleObjectProperty() { @Override protected void invalidated() { Account account = get(); if (account == null) { titleProperty().unbind(); + subtitleProperty().unbind(); + tooltip.textProperty().unbind(); setTitle(i18n("account.missing")); setSubtitle(i18n("account.missing.add")); - imageProperty().unbind(); - setImage(new Image("/assets/img/craft_table.png")); + tooltip.setText(i18n("account.create")); + + TexturesLoader.unbindAvatar(canvas); + TexturesLoader.drawAvatar(canvas, TexturesLoader.getDefaultSkinImage()); + } else { - titleProperty().bind(Bindings.createStringBinding(account::getCharacter, account)); - setSubtitle(accountSubtitle(account)); - imageProperty().bind(TexturesLoader.fxAvatarBinding(account, 32)); + titleProperty().bind(BindingMapping.of(account, Account::getCharacter)); + subtitleProperty().bind(accountSubtitle(account)); + tooltip.textProperty().bind(accountTooltip(account)); + TexturesLoader.bindAvatar(canvas, account); } } }; public AccountAdvancedListItem() { - setRightGraphic(SVG.viewList(Theme.blackFillBinding(), -1, -1)); + tooltip = new Tooltip(); + FXUtils.installFastTooltip(this, tooltip); + + canvas = new Canvas(32, 32); + setLeftGraphic(canvas); + + setActionButtonVisible(false); + + FXUtils.onScroll(this, Accounts.getAccounts(), + accounts -> accounts.indexOf(account.get()), + Accounts::setSelectedAccount); } public ObjectProperty accountProperty() { return account; } - private static String accountSubtitle(Account account) { - if (account instanceof OfflineAccount) - return i18n("account.methods.offline"); - else if (account instanceof YggdrasilAccount) - return account.getUsername(); - else - return ""; + private static ObservableValue accountSubtitle(Account account) { + if (account instanceof AuthlibInjectorAccount) { + return BindingMapping.of(((AuthlibInjectorAccount) account).getServer(), AuthlibInjectorServer::getName); + } else { + return createStringBinding(() -> getLocalizedLoginTypeName(getAccountFactory(account))); + } } + + private static ObservableValue accountTooltip(Account account) { + if (account instanceof AuthlibInjectorAccount) { + AuthlibInjectorServer server = ((AuthlibInjectorAccount) account).getServer(); + return Bindings.format("%s (%s) (%s)", + BindingMapping.of(account, Account::getCharacter), + account.getUsername(), + BindingMapping.of(server, AuthlibInjectorServer::getName)); + } else if (account instanceof YggdrasilAccount) { + return Bindings.format("%s (%s)", + BindingMapping.of(account, Account::getCharacter), + account.getUsername()); + } else { + return BindingMapping.of(account, Account::getCharacter); + } + } + } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountList.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountList.java deleted file mode 100644 index 6d1c6bf221..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountList.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.account; - -import javafx.beans.property.*; -import javafx.collections.FXCollections; -import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.ui.Controllers; -import org.jackhuang.hmcl.ui.ListPage; -import org.jackhuang.hmcl.ui.decorator.DecoratorPage; -import org.jackhuang.hmcl.util.javafx.MappedObservableList; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor; - -public class AccountList extends ListPage implements DecoratorPage { - private final ReadOnlyStringWrapper title = new ReadOnlyStringWrapper(this, "title", i18n("account.manage")); - private final ListProperty accounts = new SimpleListProperty<>(this, "accounts", FXCollections.observableArrayList()); - private final ObjectProperty selectedAccount; - - public AccountList() { - setItems(MappedObservableList.create(accounts, AccountListItem::new)); - selectedAccount = createSelectedItemPropertyFor(getItems(), Account.class); - } - - public ObjectProperty selectedAccountProperty() { - return selectedAccount; - } - - public ListProperty accountsProperty() { - return accounts; - } - - @Override - public void add() { - Controllers.dialog(new AddAccountPane()); - } - - @Override - public ReadOnlyStringProperty titleProperty() { - return title.getReadOnlyProperty(); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java index 1d1e92fefa..65bbabb6a8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,33 +18,51 @@ package org.jackhuang.hmcl.ui.account; import javafx.beans.binding.Bindings; +import javafx.beans.binding.ObjectBinding; import javafx.beans.binding.StringBinding; -import javafx.beans.property.*; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.beans.value.ObservableBooleanValue; import javafx.scene.control.RadioButton; import javafx.scene.control.Skin; import javafx.scene.image.Image; +import javafx.stage.FileChooser; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.CredentialExpiredException; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.auth.offline.OfflineAccount; -import org.jackhuang.hmcl.game.TexturesLoader; +import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; +import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.DialogController; - -import static org.jackhuang.hmcl.util.Lang.thread; -import static org.jackhuang.hmcl.util.Logging.LOG; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.skin.InvalidSkinException; +import org.jackhuang.hmcl.util.skin.NormalizedSkin; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CancellationException; + +import static java.util.Collections.emptySet; +import static javafx.beans.binding.Bindings.createBooleanBinding; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -import java.util.logging.Level; - public class AccountListItem extends RadioButton { private final Account account; private final StringProperty title = new SimpleStringProperty(); private final StringProperty subtitle = new SimpleStringProperty(); - private final ObjectProperty image = new SimpleObjectProperty<>(); public AccountListItem(Account account) { this.account = account; @@ -52,23 +70,24 @@ public AccountListItem(Account account) { setUserData(account); String loginTypeName = Accounts.getLocalizedLoginTypeName(Accounts.getAccountFactory(account)); + String portableSuffix = account.isPortable() ? ", " + i18n("account.portable") : ""; if (account instanceof AuthlibInjectorAccount) { AuthlibInjectorServer server = ((AuthlibInjectorAccount) account).getServer(); subtitle.bind(Bindings.concat( loginTypeName, ", ", i18n("account.injector.server"), ": ", - Bindings.createStringBinding(server::getName, server))); + Bindings.createStringBinding(server::getName, server), portableSuffix)); } else { - subtitle.set(loginTypeName); + subtitle.set(loginTypeName + portableSuffix); } StringBinding characterName = Bindings.createStringBinding(account::getCharacter, account); if (account instanceof OfflineAccount) { title.bind(characterName); } else { - title.bind(Bindings.concat(account.getUsername(), " - ", characterName)); + title.bind( + account.getUsername().isEmpty() ? characterName : + Bindings.concat(account.getUsername(), " - ", characterName)); } - - image.bind(TexturesLoader.fxAvatarBinding(account, 32)); } @Override @@ -76,23 +95,88 @@ protected Skin createDefaultSkin() { return new AccountListItemSkin(this); } - public void refresh() { - account.clearCache(); - thread(() -> { + public Task refreshAsync() { + return Task.runAsync(() -> { + account.clearCache(); try { account.logIn(); } catch (CredentialExpiredException e) { try { DialogController.logIn(account); + } catch (CancellationException e1) { + // ignore cancellation } catch (Exception e1) { - LOG.log(Level.WARNING, "Failed to refresh " + account + " with password", e1); + LOG.warning("Failed to refresh " + account + " with password", e1); + throw e1; } } catch (AuthenticationException e) { - LOG.log(Level.WARNING, "Failed to refresh " + account + " with token", e); + LOG.warning("Failed to refresh " + account + " with token", e); + throw e; } }); } + public ObservableBooleanValue canUploadSkin() { + if (account instanceof AuthlibInjectorAccount aiAccount) { + ObjectBinding> profile = aiAccount.getYggdrasilService().getProfileRepository().binding(aiAccount.getUUID()); + return createBooleanBinding(() -> { + Set uploadableTextures = profile.get() + .map(AuthlibInjectorAccount::getUploadableTextures) + .orElse(emptySet()); + return uploadableTextures.contains(TextureType.SKIN); + }, profile); + } else if (account instanceof OfflineAccount || account.canUploadSkin()) { + return createBooleanBinding(() -> true); + } else { + return createBooleanBinding(() -> false); + } + } + + /** + * @return the skin upload task, null if no file is selected + */ + @Nullable + public Task uploadSkin() { + if (account instanceof OfflineAccount) { + Controllers.dialog(new OfflineAccountSkinPane((OfflineAccount) account)); + return null; + } + if (!account.canUploadSkin()) { + return null; + } + + FileChooser chooser = new FileChooser(); + chooser.setTitle(i18n("account.skin.upload")); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("account.skin.file"), "*.png")); + Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage())); + if (selectedFile == null) { + return null; + } + + return refreshAsync() + .thenRunAsync(() -> { + Image skinImg; + try (var input = Files.newInputStream(selectedFile)) { + skinImg = new Image(input); + } catch (IOException e) { + throw new InvalidSkinException("Failed to read skin image", e); + } + if (skinImg.isError()) { + throw new InvalidSkinException("Failed to read skin image", skinImg.getException()); + } + NormalizedSkin skin = new NormalizedSkin(skinImg); + String model = skin.isSlim() ? "slim" : ""; + LOG.info("Uploading skin [" + selectedFile + "], model [" + model + "]"); + account.uploadSkin(skin.isSlim(), selectedFile); + }) + .thenComposeAsync(refreshAsync()) + .whenComplete(Schedulers.javafx(), e -> { + if (e != null) { + Controllers.dialog(Accounts.localizeErrorMessage(e), i18n("account.skin.upload.failed"), MessageType.ERROR); + } + }); + } + public void remove() { Accounts.getAccounts().remove(account); } @@ -124,16 +208,4 @@ public void setSubtitle(String subtitle) { public StringProperty subtitleProperty() { return subtitle; } - - public Image getImage() { - return image.get(); - } - - public void setImage(Image image) { - this.image.set(image); - } - - public ObjectProperty imageProperty() { - return image; - } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java index 0eca64e0e8..1ce5cd280f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,43 +17,47 @@ */ package org.jackhuang.hmcl.ui.account; -import com.jfoenix.concurrency.JFXUtilities; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXRadioButton; import com.jfoenix.effects.JFXDepthManager; - import javafx.beans.binding.Bindings; import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.canvas.Canvas; import javafx.scene.control.Label; import javafx.scene.control.SkinBase; import javafx.scene.control.Tooltip; -import javafx.scene.image.ImageView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; - +import org.jackhuang.hmcl.auth.Account; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; +import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; +import org.jackhuang.hmcl.game.TexturesLoader; +import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.construct.SpinnerPane; +import org.jackhuang.hmcl.util.javafx.BindingMapping; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; - -public class AccountListItemSkin extends SkinBase { +public final class AccountListItemSkin extends SkinBase { public AccountListItemSkin(AccountListItem skinnable) { super(skinnable); BorderPane root = new BorderPane(); + root.setCursor(Cursor.HAND); + FXUtils.onClicked(root, skinnable::fire); - JFXRadioButton chkSelected = new JFXRadioButton() { - @Override - public void fire() { - skinnable.fire(); - } - }; + JFXRadioButton chkSelected = new JFXRadioButton(); + chkSelected.setMouseTransparent(true); BorderPane.setAlignment(chkSelected, Pos.CENTER); chkSelected.selectedProperty().bind(skinnable.selectedProperty()); root.setLeft(chkSelected); @@ -62,9 +66,8 @@ public void fire() { center.setSpacing(8); center.setAlignment(Pos.CENTER_LEFT); - ImageView imageView = new ImageView(); - FXUtils.limitSize(imageView, 32, 32); - imageView.imageProperty().bind(skinnable.imageProperty()); + Canvas canvas = new Canvas(32, 32); + TexturesLoader.bindAvatar(canvas, skinnable.getAccount()); Label title = new Label(); title.getStyleClass().add("title"); @@ -75,35 +78,119 @@ public void fire() { if (skinnable.getAccount() instanceof AuthlibInjectorAccount) { Tooltip tooltip = new Tooltip(); AuthlibInjectorServer server = ((AuthlibInjectorAccount) skinnable.getAccount()).getServer(); - tooltip.textProperty().bind(Bindings.createStringBinding(server::toString, server)); + tooltip.textProperty().bind(BindingMapping.of(server, AuthlibInjectorServer::toString)); FXUtils.installSlowTooltip(subtitle, tooltip); } VBox item = new VBox(title, subtitle); item.getStyleClass().add("two-line-list-item"); BorderPane.setAlignment(item, Pos.CENTER); - center.getChildren().setAll(imageView, item); + center.getChildren().setAll(canvas, item); root.setCenter(center); HBox right = new HBox(); right.setAlignment(Pos.CENTER_RIGHT); + + JFXButton btnMove = new JFXButton(); + SpinnerPane spinnerMove = new SpinnerPane(); + spinnerMove.getStyleClass().add("small-spinner-pane"); + btnMove.setOnAction(e -> { + Account account = skinnable.getAccount(); + Accounts.getAccounts().remove(account); + if (account.isPortable()) { + account.setPortable(false); + if (!Accounts.getAccounts().contains(account)) + Accounts.getAccounts().add(account); + } else { + account.setPortable(true); + if (!Accounts.getAccounts().contains(account)) { + int idx = 0; + for (int i = Accounts.getAccounts().size() - 1; i >= 0; i--) { + if (Accounts.getAccounts().get(i).isPortable()) { + idx = i + 1; + break; + } + } + Accounts.getAccounts().add(idx, account); + } + } + }); + btnMove.getStyleClass().add("toggle-icon4"); + if (skinnable.getAccount().isPortable()) { + btnMove.setGraphic(SVG.PUBLIC.createIcon(Theme.blackFill(), -1)); + FXUtils.installFastTooltip(btnMove, i18n("account.move_to_global")); + } else { + btnMove.setGraphic(SVG.OUTPUT.createIcon(Theme.blackFill(), -1)); + FXUtils.installFastTooltip(btnMove, i18n("account.move_to_portable")); + } + spinnerMove.setContent(btnMove); + right.getChildren().add(spinnerMove); + JFXButton btnRefresh = new JFXButton(); - btnRefresh.setOnMouseClicked(e -> skinnable.refresh()); + SpinnerPane spinnerRefresh = new SpinnerPane(); + spinnerRefresh.getStyleClass().setAll("small-spinner-pane"); + if (skinnable.getAccount() instanceof MicrosoftAccount && Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) { + btnRefresh.setDisable(true); + FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot")); + } + btnRefresh.setOnAction(e -> { + spinnerRefresh.showSpinner(); + skinnable.refreshAsync() + .whenComplete(Schedulers.javafx(), ex -> { + spinnerRefresh.hideSpinner(); + + if (ex != null) { + Controllers.showToast(Accounts.localizeErrorMessage(ex)); + } + }) + .start(); + }); btnRefresh.getStyleClass().add("toggle-icon4"); - btnRefresh.setGraphic(SVG.refresh(Theme.blackFillBinding(), -1, -1)); - JFXUtilities.runInFX(() -> FXUtils.installFastTooltip(btnRefresh, i18n("button.refresh"))); - right.getChildren().add(btnRefresh); + btnRefresh.setGraphic(SVG.REFRESH.createIcon(Theme.blackFill(), -1)); + FXUtils.installFastTooltip(btnRefresh, i18n("button.refresh")); + spinnerRefresh.setContent(btnRefresh); + right.getChildren().add(spinnerRefresh); + + JFXButton btnUpload = new JFXButton(); + SpinnerPane spinnerUpload = new SpinnerPane(); + btnUpload.setOnAction(e -> { + Task uploadTask = skinnable.uploadSkin(); + if (uploadTask != null) { + spinnerUpload.showSpinner(); + uploadTask + .whenComplete(Schedulers.javafx(), ex -> spinnerUpload.hideSpinner()) + .start(); + } + }); + btnUpload.getStyleClass().add("toggle-icon4"); + btnUpload.setGraphic(SVG.CHECKROOM.createIcon(Theme.blackFill(), -1)); + FXUtils.installFastTooltip(btnUpload, i18n("account.skin.upload")); + btnUpload.disableProperty().bind(Bindings.not(skinnable.canUploadSkin())); + spinnerUpload.setContent(btnUpload); + spinnerUpload.getStyleClass().add("small-spinner-pane"); + right.getChildren().add(spinnerUpload); + + JFXButton btnCopyUUID = new JFXButton(); + SpinnerPane spinnerCopyUUID = new SpinnerPane(); + spinnerCopyUUID.getStyleClass().add("small-spinner-pane"); + btnUpload.getStyleClass().add("toggle-icon4"); + btnCopyUUID.setOnAction(e -> FXUtils.copyText(skinnable.getAccount().getUUID().toString())); + btnCopyUUID.setGraphic(SVG.CONTENT_COPY.createIcon(Theme.blackFill(), -1)); + FXUtils.installFastTooltip(btnCopyUUID, i18n("account.copy_uuid")); + spinnerCopyUUID.setContent(btnCopyUUID); + right.getChildren().add(spinnerCopyUUID); JFXButton btnRemove = new JFXButton(); - btnRemove.setOnMouseClicked(e -> skinnable.remove()); + btnRemove.setOnAction(e -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::remove, null)); btnRemove.getStyleClass().add("toggle-icon4"); BorderPane.setAlignment(btnRemove, Pos.CENTER); - btnRemove.setGraphic(SVG.delete(Theme.blackFillBinding(), -1, -1)); - JFXUtilities.runInFX(() -> FXUtils.installFastTooltip(btnRemove, i18n("button.delete"))); + btnRemove.setGraphic(SVG.DELETE.createIcon(Theme.blackFill(), -1)); + FXUtils.installFastTooltip(btnRemove, i18n("button.delete")); right.getChildren().add(btnRemove); root.setRight(right); - root.setStyle("-fx-background-color: white; -fx-padding: 8 8 8 0;"); + root.getStyleClass().add("card"); + root.setStyle("-fx-padding: 8 8 8 0;"); JFXDepthManager.setDepth(root, 1); getChildren().setAll(root); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java new file mode 100644 index 0000000000..bfaafc74ae --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java @@ -0,0 +1,261 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.account; + +import com.jfoenix.controls.JFXButton; +import javafx.beans.binding.Bindings; +import javafx.beans.property.*; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Skin; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import org.jackhuang.hmcl.auth.Account; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; +import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.construct.AdvancedListItem; +import org.jackhuang.hmcl.ui.construct.ClassTitle; +import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; +import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.util.i18n.LocaleUtils; +import org.jackhuang.hmcl.util.io.NetworkUtils; +import org.jackhuang.hmcl.util.javafx.BindingMapping; +import org.jackhuang.hmcl.util.javafx.MappedObservableList; +import org.jackhuang.hmcl.util.platform.NativeUtils; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.platform.windows.Kernel32; +import org.jackhuang.hmcl.util.platform.windows.WinConstants; + +import java.time.Duration; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Locale; + +import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; +import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor; + +public final class AccountListPage extends DecoratorAnimatedPage implements DecoratorPage { + static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(true); + + private static boolean isExemptedRegion() { + if ("Asia/Shanghai".equals(ZoneId.systemDefault().getId())) + return true; + + // Check if the time zone is UTC+8 + if (ZonedDateTime.now().getOffset().getTotalSeconds() == Duration.ofHours(8).toSeconds()) { + if ("CN".equals(LocaleUtils.SYSTEM_DEFAULT.getCountry())) + return true; + + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && NativeUtils.USE_JNA) { + Kernel32 kernel32 = Kernel32.INSTANCE; + + // https://learn.microsoft.com/windows/win32/intl/table-of-geographical-locations + if (kernel32 != null && kernel32.GetUserGeoID(WinConstants.GEOCLASS_NATION) == 45) // China + return true; + } + } + + return false; + } + + static { + String property = System.getProperty("hmcl.offline.auth.restricted", "auto"); + + if ("false".equals(property) + || "auto".equals(property) && isExemptedRegion() + || globalConfig().isEnableOfflineAccount()) + RESTRICTED.set(false); + else + globalConfig().enableOfflineAccountProperty().addListener(new ChangeListener() { + @Override + public void changed(ObservableValue o, Boolean oldValue, Boolean newValue) { + if (newValue) { + globalConfig().enableOfflineAccountProperty().removeListener(this); + RESTRICTED.set(false); + } + } + }); + } + + private final ObservableList items; + private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("account.manage"))); + private final ListProperty accounts = new SimpleListProperty<>(this, "accounts", FXCollections.observableArrayList()); + private final ListProperty authServers = new SimpleListProperty<>(this, "authServers", FXCollections.observableArrayList()); + private final ObjectProperty selectedAccount; + + public AccountListPage() { + items = MappedObservableList.create(accounts, AccountListItem::new); + selectedAccount = createSelectedItemPropertyFor(items, Account.class); + } + + public ObjectProperty selectedAccountProperty() { + return selectedAccount; + } + + public ListProperty accountsProperty() { + return accounts; + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + return state.getReadOnlyProperty(); + } + + public ListProperty authServersProperty() { + return authServers; + } + + @Override + protected Skin createDefaultSkin() { + return new AccountListPageSkin(this); + } + + private static class AccountListPageSkin extends DecoratorAnimatedPageSkin { + + private final ObservableList authServerItems; + private ChangeListener holder; + + public AccountListPageSkin(AccountListPage skinnable) { + super(skinnable); + + { + VBox boxMethods = new VBox(); + { + boxMethods.getStyleClass().add("advanced-list-box-content"); + FXUtils.setLimitWidth(boxMethods, 200); + + AdvancedListItem microsoftItem = new AdvancedListItem(); + microsoftItem.getStyleClass().add("navigation-drawer-item"); + microsoftItem.setActionButtonVisible(false); + microsoftItem.setTitle(i18n("account.methods.microsoft")); + microsoftItem.setLeftGraphic(wrap(SVG.MICROSOFT)); + microsoftItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_MICROSOFT))); + + AdvancedListItem offlineItem = new AdvancedListItem(); + offlineItem.getStyleClass().add("navigation-drawer-item"); + offlineItem.setActionButtonVisible(false); + offlineItem.setTitle(i18n("account.methods.offline")); + offlineItem.setLeftGraphic(wrap(SVG.PERSON)); + offlineItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_OFFLINE))); + + VBox boxAuthServers = new VBox(); + authServerItems = MappedObservableList.create(skinnable.authServersProperty(), server -> { + AdvancedListItem item = new AdvancedListItem(); + item.getStyleClass().add("navigation-drawer-item"); + item.setLeftGraphic(wrap(SVG.DRESSER)); + item.setOnAction(e -> Controllers.dialog(new CreateAccountPane(server))); + + JFXButton btnRemove = new JFXButton(); + btnRemove.setOnAction(e -> { + Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> { + skinnable.authServersProperty().remove(server); + }, null); + e.consume(); + }); + btnRemove.getStyleClass().add("toggle-icon4"); + btnRemove.setGraphic(SVG.CLOSE.createIcon(Theme.blackFill(), 14)); + item.setRightGraphic(btnRemove); + + ObservableValue title = BindingMapping.of(server, AuthlibInjectorServer::getName); + item.titleProperty().bind(title); + String host = ""; + try { + host = NetworkUtils.toURI(server.getUrl()).getHost(); + } catch (IllegalArgumentException e) { + LOG.warning("Unparsable authlib-injector server url " + server.getUrl(), e); + } + item.subtitleProperty().set(host); + Tooltip tooltip = new Tooltip(); + tooltip.textProperty().bind(Bindings.format("%s (%s)", title, server.getUrl())); + FXUtils.installFastTooltip(item, tooltip); + + return item; + }); + Bindings.bindContent(boxAuthServers.getChildren(), authServerItems); + + ClassTitle title = new ClassTitle(i18n("account.create").toUpperCase(Locale.ROOT)); + if (RESTRICTED.get()) { + VBox wrapper = new VBox(offlineItem, boxAuthServers); + wrapper.setPadding(Insets.EMPTY); + FXUtils.installFastTooltip(wrapper, i18n("account.login.restricted")); + + offlineItem.setDisable(true); + boxAuthServers.setDisable(true); + + boxMethods.getChildren().setAll(title, microsoftItem, wrapper); + + holder = FXUtils.onWeakChange(RESTRICTED, value -> { + if (!value) { + holder = null; + offlineItem.setDisable(false); + boxAuthServers.setDisable(false); + boxMethods.getChildren().setAll(title, microsoftItem, offlineItem, boxAuthServers); + } + }); + } else { + boxMethods.getChildren().setAll(title, microsoftItem, offlineItem, boxAuthServers); + } + } + + AdvancedListItem addAuthServerItem = new AdvancedListItem(); + { + addAuthServerItem.getStyleClass().add("navigation-drawer-item"); + addAuthServerItem.setTitle(i18n("account.injector.add")); + addAuthServerItem.setSubtitle(i18n("account.methods.authlib_injector")); + addAuthServerItem.setActionButtonVisible(false); + addAuthServerItem.setLeftGraphic(wrap(SVG.ADD_CIRCLE)); + addAuthServerItem.setOnAction(e -> Controllers.dialog(new AddAuthlibInjectorServerPane())); + VBox.setMargin(addAuthServerItem, new Insets(0, 0, 12, 0)); + } + + ScrollPane scrollPane = new ScrollPane(boxMethods); + VBox.setVgrow(scrollPane, Priority.ALWAYS); + setLeft(scrollPane, addAuthServerItem); + } + + ScrollPane scrollPane = new ScrollPane(); + VBox list = new VBox(); + { + scrollPane.setFitToWidth(true); + + list.maxWidthProperty().bind(scrollPane.widthProperty()); + list.setSpacing(10); + list.getStyleClass().add("card-list"); + + Bindings.bindContent(list.getChildren(), skinnable.items); + + scrollPane.setContent(list); + FXUtils.smoothScrolling(scrollPane); + + setCenter(scrollPane); + } + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountLoginPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountLoginPane.java deleted file mode 100644 index 3a758bed56..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountLoginPane.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.account; - -import com.jfoenix.controls.JFXPasswordField; -import com.jfoenix.controls.JFXProgressBar; -import javafx.fxml.FXML; -import javafx.scene.control.Label; -import javafx.scene.layout.StackPane; -import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.auth.AuthInfo; -import org.jackhuang.hmcl.auth.NoSelectedCharacterException; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; - -import java.util.function.Consumer; - -public class AccountLoginPane extends StackPane { - private final Account oldAccount; - private final Consumer success; - private final Runnable failed; - - @FXML private Label lblUsername; - @FXML private JFXPasswordField txtPassword; - @FXML private Label lblCreationWarning; - @FXML private JFXProgressBar progressBar; - - public AccountLoginPane(Account oldAccount, Consumer success, Runnable failed) { - this.oldAccount = oldAccount; - this.success = success; - this.failed = failed; - - FXUtils.loadFXML(this, "/assets/fxml/account-login.fxml"); - - lblUsername.setText(oldAccount.getUsername()); - txtPassword.setOnAction(e -> onAccept()); - } - - @FXML - private void onAccept() { - String password = txtPassword.getText(); - progressBar.setVisible(true); - lblCreationWarning.setText(""); - Task.ofResult(() -> oldAccount.logInWithPassword(password)) - .finalizedResult(Schedulers.javafx(), authInfo -> { - success.accept(authInfo); - fireEvent(new DialogCloseEvent()); - progressBar.setVisible(false); - }, e -> { - if (e instanceof NoSelectedCharacterException) { - fireEvent(new DialogCloseEvent()); - } else { - lblCreationWarning.setText(AddAccountPane.accountException(e)); - } - progressBar.setVisible(false); - }).start(); - } - - @FXML - private void onCancel() { - failed.run(); - fireEvent(new DialogCloseEvent()); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAccountPane.java deleted file mode 100644 index dacc36120e..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAccountPane.java +++ /dev/null @@ -1,330 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.account; - -import com.jfoenix.concurrency.JFXUtilities; -import com.jfoenix.controls.*; - -import javafx.application.Platform; -import javafx.beans.binding.Bindings; -import javafx.beans.property.ListProperty; -import javafx.beans.property.ReadOnlyObjectProperty; -import javafx.beans.property.SimpleListProperty; -import javafx.collections.FXCollections; -import javafx.fxml.FXML; -import javafx.geometry.Pos; -import javafx.scene.control.Hyperlink; -import javafx.scene.control.Label; -import javafx.scene.image.ImageView; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.StackPane; -import org.jackhuang.hmcl.auth.*; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; -import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; -import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; -import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; -import org.jackhuang.hmcl.game.TexturesLoader; -import org.jackhuang.hmcl.setting.Accounts; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.ui.Controllers; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.util.javafx.MultiStepBinding; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import static java.util.Collections.emptyList; -import static java.util.Collections.unmodifiableList; -import static java.util.Objects.requireNonNull; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.ui.FXUtils.*; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -public class AddAccountPane extends StackPane { - - @FXML private JFXTextField txtUsername; - @FXML private JFXPasswordField txtPassword; - @FXML private Label lblCreationWarning; - @FXML private Label lblPassword; - @FXML private JFXComboBox> cboType; - @FXML private JFXComboBox cboServers; - @FXML private Label lblInjectorServer; - @FXML private JFXButton btnAccept; - @FXML private JFXButton btnAddServer; - @FXML private JFXButton btnManageServer; - @FXML private SpinnerPane acceptPane; - @FXML private HBox linksContainer; - - private ListProperty links = new SimpleListProperty<>();; - - public AddAccountPane() { - FXUtils.loadFXML(this, "/assets/fxml/account-add.fxml"); - - cboServers.setCellFactory(jfxListCellFactory(server -> new TwoLineListItem(server.getName(), server.getUrl()))); - cboServers.setConverter(stringConverter(AuthlibInjectorServer::getName)); - Bindings.bindContent(cboServers.getItems(), config().getAuthlibInjectorServers()); - cboServers.getItems().addListener(onInvalidating(this::selectDefaultServer)); - selectDefaultServer(); - - cboType.getItems().setAll(Accounts.FACTORY_OFFLINE, Accounts.FACTORY_MOJANG, Accounts.FACTORY_AUTHLIB_INJECTOR); - cboType.setConverter(stringConverter(Accounts::getLocalizedLoginTypeName)); - // try selecting the preferred login type - cboType.getSelectionModel().select( - cboType.getItems().stream() - .filter(type -> Accounts.getLoginType(type).equals(config().getPreferredLoginType())) - .findFirst() - .orElse(Accounts.FACTORY_OFFLINE)); - - btnAddServer.visibleProperty().bind(cboServers.visibleProperty()); - btnManageServer.visibleProperty().bind(cboServers.visibleProperty()); - - cboServers.getItems().addListener(onInvalidating(this::checkIfNoServer)); - checkIfNoServer(); - - ReadOnlyObjectProperty> loginType = cboType.getSelectionModel().selectedItemProperty(); - - // remember the last used login type - loginType.addListener((observable, oldValue, newValue) -> config().setPreferredLoginType(Accounts.getLoginType(newValue))); - - txtPassword.visibleProperty().bind(loginType.isNotEqualTo(Accounts.FACTORY_OFFLINE)); - lblPassword.visibleProperty().bind(txtPassword.visibleProperty()); - - cboServers.visibleProperty().bind(loginType.isEqualTo(Accounts.FACTORY_AUTHLIB_INJECTOR)); - lblInjectorServer.visibleProperty().bind(cboServers.visibleProperty()); - - txtUsername.getValidators().add(new Validator(i18n("input.email"), str -> !txtPassword.isVisible() || str.contains("@"))); - - btnAccept.disableProperty().bind(Bindings.createBooleanBinding( - () -> !( // consider the opposite situation: input is valid - txtUsername.validate() && - // invisible means the field is not needed, neither should it be validated - (!txtPassword.isVisible() || txtPassword.validate()) && - (!cboServers.isVisible() || cboServers.getSelectionModel().getSelectedItem() != null) - ), - txtUsername.textProperty(), - txtPassword.textProperty(), txtPassword.visibleProperty(), - cboServers.getSelectionModel().selectedItemProperty(), cboServers.visibleProperty())); - - // authlib-injector links - links.bind(MultiStepBinding.of(cboServers.getSelectionModel().selectedItemProperty()) - .map(AddAccountPane::createHyperlinks) - .map(FXCollections::observableList)); - Bindings.bindContent(linksContainer.getChildren(), links); - linksContainer.visibleProperty().bind(cboServers.visibleProperty()); - } - - private static final String[] ALLOWED_LINKS = { "register" }; - - public static List createHyperlinks(AuthlibInjectorServer server) { - if (server == null) { - return emptyList(); - } - - Map links = server.getLinks(); - List result = new ArrayList<>(); - for (String key : ALLOWED_LINKS) { - String value = links.get(key); - if (value != null) { - Hyperlink link = new Hyperlink(i18n("account.injector.link." + key)); - FXUtils.installSlowTooltip(link, value); - link.setOnAction(e -> FXUtils.openLink(value)); - result.add(link); - } - } - return unmodifiableList(result); - } - - /** - * Selects the first server if no server is selected. - */ - private void selectDefaultServer() { - if (!cboServers.getItems().isEmpty() && cboServers.getSelectionModel().isEmpty()) { - cboServers.getSelectionModel().select(0); - } - } - - private void checkIfNoServer() { - if (cboServers.getItems().isEmpty()) - cboServers.getStyleClass().setAll("jfx-combo-box-warning"); - else - cboServers.getStyleClass().setAll("jfx-combo-box"); - } - - /** - * Gets the additional data that needs to be passed into {@link AccountFactory#create(CharacterSelector, String, String, Object)}. - */ - private Object getAuthAdditionalData() { - AccountFactory factory = cboType.getSelectionModel().getSelectedItem(); - if (factory == Accounts.FACTORY_AUTHLIB_INJECTOR) { - return requireNonNull(cboServers.getSelectionModel().getSelectedItem(), "selected server cannot be null"); - } - return null; - } - - @FXML - private void onCreationAccept() { - if (btnAccept.isDisabled()) - return; - - acceptPane.showSpinner(); - lblCreationWarning.setText(""); - setDisable(true); - - String username = txtUsername.getText(); - String password = txtPassword.getText(); - AccountFactory factory = cboType.getSelectionModel().getSelectedItem(); - Object additionalData = getAuthAdditionalData(); - - Task.ofResult(() -> factory.create(new Selector(), username, password, additionalData)) - .finalizedResult(Schedulers.javafx(), account -> { - int oldIndex = Accounts.getAccounts().indexOf(account); - if (oldIndex == -1) { - Accounts.getAccounts().add(account); - } else { - // adding an already-added account - // instead of discarding the new account, we first remove the existing one then add the new one - Accounts.getAccounts().remove(oldIndex); - Accounts.getAccounts().add(oldIndex, account); - } - - // select the new account - Accounts.setSelectedAccount(account); - - acceptPane.hideSpinner(); - fireEvent(new DialogCloseEvent()); - }, exception -> { - if (exception instanceof NoSelectedCharacterException) { - fireEvent(new DialogCloseEvent()); - } else { - lblCreationWarning.setText(accountException(exception)); - } - setDisable(false); - acceptPane.hideSpinner(); - }).start(); - } - - @FXML - private void onCreationCancel() { - fireEvent(new DialogCloseEvent()); - } - - @FXML - private void onManageInjecterServers() { - fireEvent(new DialogCloseEvent()); - Controllers.navigate(Controllers.getServersPage()); - } - - @FXML - private void onAddInjecterServer() { - Controllers.dialog(new AddAuthlibInjectorServerPane()); - } - - private class Selector extends BorderPane implements CharacterSelector { - - private final AdvancedListBox listBox = new AdvancedListBox(); - private final JFXButton cancel = new JFXButton(); - - private final CountDownLatch latch = new CountDownLatch(1); - private GameProfile selectedProfile = null; - - public Selector() { - setStyle("-fx-padding: 8px;"); - - cancel.setText(i18n("button.cancel")); - StackPane.setAlignment(cancel, Pos.BOTTOM_RIGHT); - cancel.setOnMouseClicked(e -> latch.countDown()); - - listBox.startCategory(i18n("account.choose")); - - setCenter(listBox); - - HBox hbox = new HBox(); - hbox.setAlignment(Pos.CENTER_RIGHT); - hbox.getChildren().add(cancel); - setBottom(hbox); - } - - @Override - public GameProfile select(YggdrasilService service, List profiles) throws NoSelectedCharacterException { - Platform.runLater(() -> { - for (GameProfile profile : profiles) { - ImageView portraitView = new ImageView(); - portraitView.setSmooth(false); - portraitView.imageProperty().bind(TexturesLoader.fxAvatarBinding(service, profile.getId(), 32)); - FXUtils.limitSize(portraitView, 32, 32); - - IconedItem accountItem = new IconedItem(portraitView, profile.getName()); - accountItem.setOnMouseClicked(e -> { - selectedProfile = profile; - latch.countDown(); - }); - listBox.add(accountItem); - } - Controllers.dialog(this); - }); - - try { - latch.await(); - - if (selectedProfile == null) - throw new NoSelectedCharacterException(); - - return selectedProfile; - } catch (InterruptedException ignore) { - throw new NoSelectedCharacterException(); - } finally { - JFXUtilities.runInFX(() -> Selector.this.fireEvent(new DialogCloseEvent())); - } - } - } - - public static String accountException(Exception exception) { - if (exception instanceof NoCharacterException) { - return i18n("account.failed.no_character"); - } else if (exception instanceof ServerDisconnectException) { - return i18n("account.failed.connect_authentication_server"); - } else if (exception instanceof ServerResponseMalformedException) { - return i18n("account.failed.server_response_malformed"); - } else if (exception instanceof RemoteAuthenticationException) { - RemoteAuthenticationException remoteException = (RemoteAuthenticationException) exception; - String remoteMessage = remoteException.getRemoteMessage(); - if ("ForbiddenOperationException".equals(remoteException.getRemoteName()) && remoteMessage != null) { - if (remoteMessage.contains("Invalid credentials")) - return i18n("account.failed.invalid_credentials"); - else if (remoteMessage.contains("Invalid token")) - return i18n("account.failed.invalid_token"); - else if (remoteMessage.contains("Invalid username or password")) - return i18n("account.failed.invalid_password"); - } - return exception.getMessage(); - } else if (exception instanceof AuthlibInjectorDownloadException) { - return i18n("account.failed.injector_download_failure"); - } else if (exception instanceof CharacterDeletedException) { - return i18n("account.failed.character_deleted"); - } else if (exception.getClass() == AuthenticationException.class) { - return exception.getLocalizedMessage(); - } else { - return exception.getClass().getName() + ": " + exception.getLocalizedMessage(); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java index 6c8d0bd5e2..84f94d5407 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,42 +20,37 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXTextField; - -import javafx.fxml.FXML; import javafx.scene.control.Label; -import javafx.scene.layout.StackPane; +import javafx.scene.layout.*; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; -import org.jackhuang.hmcl.ui.animation.TransitionHandler; +import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.construct.DialogAware; import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; import org.jackhuang.hmcl.ui.construct.SpinnerPane; -import org.jackhuang.hmcl.util.io.NetworkUtils; +import org.jackhuang.hmcl.util.Lang; +import javax.net.ssl.SSLException; import java.io.IOException; -import java.util.logging.Level; import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.ui.FXUtils.loadFXML; -import static org.jackhuang.hmcl.util.Logging.LOG; +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -public class AddAuthlibInjectorServerPane extends StackPane implements DialogAware { - - @FXML private StackPane addServerContainer; - @FXML private Label lblServerUrl; - @FXML private Label lblServerName; - @FXML private Label lblCreationWarning; - @FXML private Label lblServerWarning; - @FXML private JFXTextField txtServerUrl; - @FXML private JFXDialogLayout addServerPane; - @FXML private JFXDialogLayout confirmServerPane; - @FXML private SpinnerPane nextPane; - @FXML private JFXButton btnAddNext; +public final class AddAuthlibInjectorServerPane extends TransitionPane implements DialogAware { - private TransitionHandler transitionHandler; + private final Label lblServerUrl; + private final Label lblServerName; + private final Label lblCreationWarning; + private final Label lblServerWarning; + private final JFXTextField txtServerUrl; + private final JFXDialogLayout addServerPane; + private final JFXDialogLayout confirmServerPane; + private final SpinnerPane nextPane; + private final JFXButton btnAddNext; private AuthlibInjectorServer serverBeingAdded; @@ -66,12 +61,98 @@ public AddAuthlibInjectorServerPane(String url) { } public AddAuthlibInjectorServerPane() { - loadFXML(this, "/assets/fxml/authlib-injector-server-add.fxml"); - transitionHandler = new TransitionHandler(addServerContainer); - transitionHandler.setContent(addServerPane, ContainerAnimations.NONE.getAnimationProducer()); + addServerPane = new JFXDialogLayout(); + addServerPane.setHeading(new Label(i18n("account.injector.add"))); + { + txtServerUrl = new JFXTextField(); + txtServerUrl.setPromptText(i18n("account.injector.server_url")); + txtServerUrl.setOnAction(e -> onAddNext()); + + lblCreationWarning = new Label(); + lblCreationWarning.setWrapText(true); + HBox actions = new HBox(); + { + JFXButton cancel = new JFXButton(i18n("button.cancel")); + cancel.getStyleClass().add("dialog-accept"); + cancel.setOnAction(e -> onAddCancel()); + + nextPane = new SpinnerPane(); + nextPane.getStyleClass().add("small-spinner-pane"); + btnAddNext = new JFXButton(i18n("wizard.next")); + btnAddNext.getStyleClass().add("dialog-accept"); + btnAddNext.setOnAction(e -> onAddNext()); + nextPane.setContent(btnAddNext); + + actions.getChildren().setAll(cancel, nextPane); + } + + addServerPane.setBody(txtServerUrl); + addServerPane.setActions(lblCreationWarning, actions); + } + + confirmServerPane = new JFXDialogLayout(); + confirmServerPane.setHeading(new Label(i18n("account.injector.add"))); + { + GridPane body = new GridPane(); + body.setStyle("-fx-padding: 15 0 0 0;"); + body.setVgap(15); + body.setHgap(15); + { + body.getColumnConstraints().setAll( + Lang.apply(new ColumnConstraints(), c -> c.setMaxWidth(100)), + new ColumnConstraints() + ); + + lblServerUrl = new Label(); + GridPane.setColumnIndex(lblServerUrl, 1); + GridPane.setRowIndex(lblServerUrl, 0); + + lblServerName = new Label(); + GridPane.setColumnIndex(lblServerName, 1); + GridPane.setRowIndex(lblServerName, 1); + + lblServerWarning = new Label(i18n("account.injector.http")); + lblServerWarning.setStyle("-fx-text-fill: red;"); + GridPane.setColumnIndex(lblServerWarning, 0); + GridPane.setRowIndex(lblServerWarning, 2); + GridPane.setColumnSpan(lblServerWarning, 2); + + body.getChildren().setAll( + Lang.apply(new Label(i18n("account.injector.server_url")), l -> { + GridPane.setColumnIndex(l, 0); + GridPane.setRowIndex(l, 0); + }), + Lang.apply(new Label(i18n("account.injector.server_name")), l -> { + GridPane.setColumnIndex(l, 0); + GridPane.setRowIndex(l, 1); + }), + lblServerUrl, lblServerName, lblServerWarning + ); + } + JFXButton prevButton = new JFXButton(i18n("wizard.prev")); + prevButton.getStyleClass().add("dialog-cancel"); + prevButton.setOnAction(e -> onAddPrev()); + + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.getStyleClass().add("dialog-cancel"); + cancelButton.setOnAction(e -> onAddCancel()); + + JFXButton finishButton = new JFXButton(i18n("wizard.finish")); + finishButton.getStyleClass().add("dialog-accept"); + finishButton.setOnAction(e -> onAddFinish()); + + confirmServerPane.setBody(body); + confirmServerPane.setActions(prevButton, cancelButton, finishButton); + } + + this.setContent(addServerPane, ContainerAnimations.NONE); + + lblCreationWarning.maxWidthProperty().bind(((FlowPane) lblCreationWarning.getParent()).widthProperty()); btnAddNext.disableProperty().bind(txtServerUrl.textProperty().isEmpty()); nextPane.hideSpinner(); + + onEscPressed(this, this::onAddCancel); } @Override @@ -80,19 +161,19 @@ public void onDialogShown() { } private String resolveFetchExceptionMessage(Throwable exception) { - if (exception instanceof IOException) { + if (exception instanceof SSLException) { + return i18n("account.failed.ssl"); + } else if (exception instanceof IOException) { return i18n("account.failed.connect_injector_server"); } else { return exception.getClass().getName() + ": " + exception.getLocalizedMessage(); } } - @FXML private void onAddCancel() { fireEvent(new DialogCloseEvent()); } - @FXML private void onAddNext() { if (btnAddNext.isDisabled()) return; @@ -104,34 +185,32 @@ private void onAddNext() { nextPane.showSpinner(); addServerPane.setDisable(true); - Task.of(() -> { + Task.runAsync(() -> { serverBeingAdded = AuthlibInjectorServer.locateServer(url); - }).finalized(Schedulers.javafx(), (variables, isDependentsSucceeded) -> { + }).whenComplete(Schedulers.javafx(), exception -> { addServerPane.setDisable(false); nextPane.hideSpinner(); - if (isDependentsSucceeded) { + if (exception == null) { lblServerName.setText(serverBeingAdded.getName()); lblServerUrl.setText(serverBeingAdded.getUrl()); - lblServerWarning.setVisible("http".equals(NetworkUtils.toURL(serverBeingAdded.getUrl()).getProtocol())); + //noinspection HttpUrlsUsage + lblServerWarning.setVisible(serverBeingAdded.getUrl().startsWith("http://")); - transitionHandler.setContent(confirmServerPane, ContainerAnimations.SWIPE_LEFT.getAnimationProducer()); + this.setContent(confirmServerPane, ContainerAnimations.SWIPE_LEFT); } else { - Exception exception = variables.get("lastException"); - LOG.log(Level.WARNING, "Failed to resolve auth server: " + url, exception); + LOG.warning("Failed to resolve auth server: " + url, exception); lblCreationWarning.setText(resolveFetchExceptionMessage(exception)); } }).start(); } - @FXML private void onAddPrev() { - transitionHandler.setContent(addServerPane, ContainerAnimations.SWIPE_RIGHT.getAnimationProducer()); + this.setContent(addServerPane, ContainerAnimations.SWIPE_RIGHT); } - @FXML private void onAddFinish() { if (!config().getAuthlibInjectorServers().contains(serverBeingAdded)) { config().getAuthlibInjectorServers().add(serverBeingAdded); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AuthlibInjectorServerItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AuthlibInjectorServerItem.java deleted file mode 100644 index f8a7776955..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AuthlibInjectorServerItem.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.account; - -import com.jfoenix.controls.JFXButton; -import com.jfoenix.effects.JFXDepthManager; - -import javafx.beans.binding.Bindings; -import javafx.geometry.Pos; -import javafx.scene.control.Label; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.VBox; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; -import org.jackhuang.hmcl.setting.Theme; -import org.jackhuang.hmcl.ui.SVG; - -import java.util.function.Consumer; - -public final class AuthlibInjectorServerItem extends BorderPane { - private final AuthlibInjectorServer server; - - private final Label lblServerName = new Label(); - private final Label lblServerUrl = new Label(); - - public AuthlibInjectorServerItem(AuthlibInjectorServer server, Consumer deleteCallback) { - this.server = server; - - lblServerName.setStyle("-fx-font-size: 15;"); - lblServerUrl.setStyle("-fx-font-size: 10;"); - - VBox center = new VBox(); - BorderPane.setAlignment(center, Pos.CENTER); - center.getChildren().addAll(lblServerName, lblServerUrl); - setCenter(center); - - JFXButton right = new JFXButton(); - right.setOnMouseClicked(e -> deleteCallback.accept(this)); - right.getStyleClass().add("toggle-icon4"); - BorderPane.setAlignment(right, Pos.CENTER); - right.setGraphic(SVG.close(Theme.blackFillBinding(), 15, 15)); - setRight(right); - - setStyle("-fx-background-radius: 2; -fx-background-color: white; -fx-padding: 8;"); - JFXDepthManager.setDepth(this, 1); - lblServerName.textProperty().bind(Bindings.createStringBinding(server::getName, server)); - lblServerUrl.setText(server.getUrl()); - } - - public AuthlibInjectorServer getServer() { - return server; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AuthlibInjectorServersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AuthlibInjectorServersPage.java deleted file mode 100644 index 588f62a80d..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AuthlibInjectorServersPage.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.account; - -import javafx.beans.binding.Bindings; -import javafx.beans.property.ReadOnlyStringProperty; -import javafx.beans.property.ReadOnlyStringWrapper; -import javafx.collections.ObservableList; -import javafx.fxml.FXML; -import javafx.scene.control.ScrollPane; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; -import org.jackhuang.hmcl.ui.Controllers; -import org.jackhuang.hmcl.ui.decorator.DecoratorPage; -import org.jackhuang.hmcl.util.javafx.MappedObservableList; - -import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.ui.FXUtils.loadFXML; -import static org.jackhuang.hmcl.ui.FXUtils.smoothScrolling; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -public class AuthlibInjectorServersPage extends StackPane implements DecoratorPage { - private final ReadOnlyStringWrapper title = new ReadOnlyStringWrapper(this, "title", i18n("account.injector.manage.title")); - - @FXML private ScrollPane scrollPane; - @FXML private VBox listPane; - @FXML private StackPane contentPane; - - private ObservableList serverItems; - - public AuthlibInjectorServersPage() { - loadFXML(this, "/assets/fxml/authlib-injector-servers.fxml"); - smoothScrolling(scrollPane); - - serverItems = MappedObservableList.create(config().getAuthlibInjectorServers(), this::createServerItem); - Bindings.bindContent(listPane.getChildren(), serverItems); - } - - private AuthlibInjectorServerItem createServerItem(AuthlibInjectorServer server) { - return new AuthlibInjectorServerItem(server, - item -> config().getAuthlibInjectorServers().remove(item.getServer())); - } - - @FXML - private void onAdd() { - Controllers.dialog(new AddAuthlibInjectorServerPane()); - } - - public String getTitle() { - return title.get(); - } - - @Override - public ReadOnlyStringProperty titleProperty() { - return title.getReadOnlyProperty(); - } - - public void setTitle(String title) { - this.title.set(title); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/ClassicAccountLoginDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/ClassicAccountLoginDialog.java new file mode 100644 index 0000000000..19bd4510e5 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/ClassicAccountLoginDialog.java @@ -0,0 +1,125 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.account; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXDialogLayout; +import com.jfoenix.controls.JFXPasswordField; +import com.jfoenix.controls.JFXProgressBar; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import org.jackhuang.hmcl.auth.AuthInfo; +import org.jackhuang.hmcl.auth.ClassicAccount; +import org.jackhuang.hmcl.auth.NoSelectedCharacterException; +import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; +import org.jackhuang.hmcl.ui.construct.RequiredValidator; + +import java.util.function.Consumer; + +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public class ClassicAccountLoginDialog extends StackPane { + private final ClassicAccount oldAccount; + private final Consumer success; + private final Runnable failed; + + private final JFXPasswordField txtPassword; + private final Label lblCreationWarning = new Label(); + private final JFXProgressBar progressBar; + + public ClassicAccountLoginDialog(ClassicAccount oldAccount, Consumer success, Runnable failed) { + this.oldAccount = oldAccount; + this.success = success; + this.failed = failed; + + progressBar = new JFXProgressBar(); + StackPane.setAlignment(progressBar, Pos.TOP_CENTER); + progressBar.setVisible(false); + + JFXDialogLayout dialogLayout = new JFXDialogLayout(); + + { + dialogLayout.setHeading(new Label(i18n("login.enter_password"))); + } + + { + VBox body = new VBox(15); + body.setPadding(new Insets(15, 0, 0, 0)); + + Label usernameLabel = new Label(oldAccount.getUsername()); + + txtPassword = new JFXPasswordField(); + txtPassword.setOnAction(e -> onAccept()); + txtPassword.getValidators().add(new RequiredValidator()); + txtPassword.setLabelFloat(true); + txtPassword.setPromptText(i18n("account.password")); + + body.getChildren().setAll(usernameLabel, txtPassword); + dialogLayout.setBody(body); + } + + { + JFXButton acceptButton = new JFXButton(i18n("button.ok")); + acceptButton.setOnAction(e -> onAccept()); + acceptButton.getStyleClass().add("dialog-accept"); + + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.setOnAction(e -> onCancel()); + cancelButton.getStyleClass().add("dialog-cancel"); + + dialogLayout.setActions(lblCreationWarning, acceptButton, cancelButton); + } + + getChildren().setAll(dialogLayout); + + onEscPressed(this, this::onCancel); + } + + private void onAccept() { + String password = txtPassword.getText(); + progressBar.setVisible(true); + lblCreationWarning.setText(""); + Task.supplyAsync(() -> oldAccount.logInWithPassword(password)) + .whenComplete(Schedulers.javafx(), authInfo -> { + success.accept(authInfo); + fireEvent(new DialogCloseEvent()); + progressBar.setVisible(false); + }, e -> { + LOG.info("Failed to login with password: " + oldAccount, e); + if (e instanceof NoSelectedCharacterException) { + fireEvent(new DialogCloseEvent()); + } else { + lblCreationWarning.setText(Accounts.localizeErrorMessage(e)); + } + progressBar.setVisible(false); + }).start(); + } + + private void onCancel() { + failed.run(); + fireEvent(new DialogCloseEvent()); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java new file mode 100644 index 0000000000..9b4bd3f09c --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java @@ -0,0 +1,756 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.account; + +import com.jfoenix.controls.*; +import com.jfoenix.validation.base.ValidatorBase; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.beans.NamedArg; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.canvas.Canvas; +import javafx.scene.control.Hyperlink; +import javafx.scene.control.Label; +import javafx.scene.control.TextInputControl; +import javafx.scene.layout.*; + +import javafx.util.Duration; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.auth.AccountFactory; +import org.jackhuang.hmcl.auth.CharacterSelector; +import org.jackhuang.hmcl.auth.NoSelectedCharacterException; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccountFactory; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; +import org.jackhuang.hmcl.auth.authlibinjector.BoundAuthlibInjectorAccountFactory; +import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccountFactory; +import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory; +import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; +import org.jackhuang.hmcl.game.OAuthServer; +import org.jackhuang.hmcl.game.TexturesLoader; +import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.task.TaskExecutor; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.WeakListenerHolder; +import org.jackhuang.hmcl.ui.construct.*; +import org.jackhuang.hmcl.upgrade.IntegrityChecker; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; +import org.jackhuang.hmcl.util.javafx.BindingMapping; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.regex.Pattern; + +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableList; +import static javafx.beans.binding.Bindings.bindContent; +import static javafx.beans.binding.Bindings.createBooleanBinding; +import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.ui.FXUtils.*; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.classPropertyFor; + +public class CreateAccountPane extends JFXDialogLayout implements DialogAware { + private static final Pattern USERNAME_CHECKER_PATTERN = Pattern.compile("^[A-Za-z0-9_]+$"); + + private boolean showMethodSwitcher; + private AccountFactory factory; + + private final Label lblErrorMessage; + private final JFXButton btnAccept; + private final SpinnerPane spinner; + private final Node body; + + private Node detailsPane; // AccountDetailsInputPane for Offline / Mojang / authlib-injector, Label for Microsoft + private final Pane detailsContainer; + + private final BooleanProperty logging = new SimpleBooleanProperty(); + private final ObjectProperty deviceCode = new SimpleObjectProperty<>(); + private final WeakListenerHolder holder = new WeakListenerHolder(); + + private TaskExecutor loginTask; + + public CreateAccountPane() { + this((AccountFactory) null); + } + + public CreateAccountPane(AccountFactory factory) { + if (factory == null) { + if (AccountListPage.RESTRICTED.get()) { + showMethodSwitcher = false; + factory = Accounts.FACTORY_MICROSOFT; + } else { + showMethodSwitcher = true; + String preferred = config().getPreferredLoginType(); + try { + factory = Accounts.getAccountFactory(preferred); + } catch (IllegalArgumentException e) { + factory = Accounts.FACTORY_OFFLINE; + } + } + } else { + showMethodSwitcher = false; + } + this.factory = factory; + + { + String title; + if (showMethodSwitcher) { + title = "account.create"; + } else { + title = "account.create." + Accounts.getLoginType(factory); + } + setHeading(new Label(i18n(title))); + } + + { + lblErrorMessage = new Label(); + lblErrorMessage.setWrapText(true); + lblErrorMessage.setMaxWidth(400); + + btnAccept = new JFXButton(i18n("account.login")); + btnAccept.getStyleClass().add("dialog-accept"); + btnAccept.setOnAction(e -> onAccept()); + + spinner = new SpinnerPane(); + spinner.getStyleClass().add("small-spinner-pane"); + spinner.setContent(btnAccept); + + JFXButton btnCancel = new JFXButton(i18n("button.cancel")); + btnCancel.getStyleClass().add("dialog-cancel"); + btnCancel.setOnAction(e -> onCancel()); + onEscPressed(this, btnCancel::fire); + + HBox hbox = new HBox(spinner, btnCancel); + hbox.setAlignment(Pos.CENTER_RIGHT); + + setActions(lblErrorMessage, hbox); + } + + if (showMethodSwitcher) { + TabControl.Tab[] tabs = new TabControl.Tab[Accounts.FACTORIES.size()]; + TabControl.Tab selected = null; + for (int i = 0; i < tabs.length; i++) { + AccountFactory f = Accounts.FACTORIES.get(i); + tabs[i] = new TabControl.Tab<>(Accounts.getLoginType(f), Accounts.getLocalizedLoginTypeName(f)); + tabs[i].setUserData(f); + if (factory == f) { + selected = tabs[i]; + } + } + + TabHeader tabHeader = new TabHeader(tabs); + tabHeader.getStyleClass().add("add-account-tab-header"); + tabHeader.setMinWidth(USE_PREF_SIZE); + tabHeader.setMaxWidth(USE_PREF_SIZE); + tabHeader.getSelectionModel().select(selected); + onChange(tabHeader.getSelectionModel().selectedItemProperty(), + newItem -> { + if (newItem == null) + return; + AccountFactory newMethod = (AccountFactory) newItem.getUserData(); + config().setPreferredLoginType(Accounts.getLoginType(newMethod)); + this.factory = newMethod; + initDetailsPane(); + }); + + detailsContainer = new StackPane(); + detailsContainer.setPadding(new Insets(15, 0, 0, 0)); + + VBox boxBody = new VBox(tabHeader, detailsContainer); + boxBody.setAlignment(Pos.CENTER); + body = boxBody; + setBody(body); + + } else { + detailsContainer = new StackPane(); + detailsContainer.setPadding(new Insets(10, 0, 0, 0)); + body = detailsContainer; + setBody(body); + } + initDetailsPane(); + + setPrefWidth(560); + } + + public CreateAccountPane(AuthlibInjectorServer authServer) { + this(Accounts.getAccountFactoryByAuthlibInjectorServer(authServer)); + } + + private void onAccept() { + spinner.showSpinner(); + lblErrorMessage.setText(""); + + if (!(factory instanceof MicrosoftAccountFactory)) { + body.setDisable(true); + } + + String username; + String password; + Object additionalData; + if (detailsPane instanceof AccountDetailsInputPane) { + AccountDetailsInputPane details = (AccountDetailsInputPane) detailsPane; + username = details.getUsername(); + password = details.getPassword(); + additionalData = details.getAdditionalData(); + } else { + username = null; + password = null; + additionalData = null; + } + + Runnable doCreate = () -> { + logging.set(true); + deviceCode.set(null); + + loginTask = Task.supplyAsync(() -> factory.create(new DialogCharacterSelector(), username, password, null, additionalData)) + .whenComplete(Schedulers.javafx(), account -> { + int oldIndex = Accounts.getAccounts().indexOf(account); + if (oldIndex == -1) { + Accounts.getAccounts().add(account); + } else { + // adding an already-added account + // instead of discarding the new account, we first remove the existing one then add the new one + Accounts.getAccounts().remove(oldIndex); + Accounts.getAccounts().add(oldIndex, account); + } + + // select the new account + Accounts.setSelectedAccount(account); + + spinner.hideSpinner(); + fireEvent(new DialogCloseEvent()); + }, exception -> { + if (exception instanceof NoSelectedCharacterException) { + fireEvent(new DialogCloseEvent()); + } else { + lblErrorMessage.setText(Accounts.localizeErrorMessage(exception)); + } + body.setDisable(false); + spinner.hideSpinner(); + }).executor(true); + }; + + if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) { + JFXButton btnYes = new JFXButton(i18n("button.ok")); + btnYes.getStyleClass().add("dialog-error"); + btnYes.setOnAction(e -> doCreate.run()); + btnYes.setDisable(true); + + int countdown = 10; + KeyFrame[] keyFrames = new KeyFrame[countdown + 1]; + for (int i = 0; i < countdown; i++) { + keyFrames[i] = new KeyFrame(Duration.seconds(i), + new KeyValue(btnYes.textProperty(), i18n("button.ok.countdown", countdown - i))); + } + keyFrames[countdown] = new KeyFrame(Duration.seconds(countdown), + new KeyValue(btnYes.textProperty(), i18n("button.ok")), + new KeyValue(btnYes.disableProperty(), false)); + + Timeline timeline = new Timeline(keyFrames); + Controllers.confirmAction( + i18n("account.methods.offline.name.invalid"), i18n("message.warning"), + MessageDialogPane.MessageType.WARNING, + btnYes, + () -> { + timeline.stop(); + body.setDisable(false); + spinner.hideSpinner(); + } + ); + timeline.play(); + } else { + doCreate.run(); + } + } + + private void onCancel() { + if (loginTask != null) { + loginTask.cancel(); + } + fireEvent(new DialogCloseEvent()); + } + + private void initDetailsPane() { + if (detailsPane != null) { + btnAccept.disableProperty().unbind(); + detailsContainer.getChildren().remove(detailsPane); + lblErrorMessage.setText(""); + } + if (factory == Accounts.FACTORY_MICROSOFT) { + VBox vbox = new VBox(8); + if (!Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) { + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO); + FXUtils.onChangeAndOperate(deviceCode, deviceCode -> { + if (deviceCode != null) { + FXUtils.copyText(deviceCode.getUserCode()); + hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri())); + } else { + hintPane.setSegment(i18n("account.methods.microsoft.hint")); + } + }); + FXUtils.onClicked(hintPane, () -> { + if (deviceCode.get() != null) { + FXUtils.copyText(deviceCode.get().getUserCode()); + } + }); + + holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value -> { + runInFX(() -> deviceCode.set(value)); + })); + FlowPane box = new FlowPane(); + box.setHgap(8); + JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth")); + birthLink.setExternalLink("https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a"); + JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile")); + profileLink.setExternalLink("https://account.live.com/editprof.aspx"); + JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase")); + purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL); + JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize")); + deauthorizeLink.setExternalLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A"); + JFXHyperlink forgotpasswordLink = new JFXHyperlink(i18n("account.methods.forgot_password")); + forgotpasswordLink.setExternalLink("https://account.live.com/ResetPassword.aspx"); + JFXHyperlink createProfileLink = new JFXHyperlink(i18n("account.methods.microsoft.makegameidsettings")); + createProfileLink.setExternalLink("https://www.minecraft.net/msaprofile/mygames/editprofile"); + JFXHyperlink bannedQueryLink = new JFXHyperlink(i18n("account.methods.ban_query")); + bannedQueryLink.setExternalLink("https://enforcement.xbox.com/enforcement/showenforcementhistory"); + box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink, forgotpasswordLink, createProfileLink, bannedQueryLink); + GridPane.setColumnSpan(box, 2); + + if (!IntegrityChecker.isOfficial()) { + HintPane unofficialHint = new HintPane(MessageDialogPane.MessageType.WARNING); + unofficialHint.setText(i18n("unofficial.hint")); + vbox.getChildren().add(unofficialHint); + } + + vbox.getChildren().addAll(hintPane, box); + + btnAccept.setDisable(false); + } else { + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING); + hintPane.setSegment(i18n("account.methods.microsoft.snapshot")); + + JFXHyperlink officialWebsite = new JFXHyperlink(i18n("account.methods.microsoft.snapshot.website")); + officialWebsite.setExternalLink(Metadata.PUBLISH_URL); + + vbox.getChildren().setAll(hintPane, officialWebsite); + btnAccept.setDisable(true); + } + + detailsPane = vbox; + } else { + detailsPane = new AccountDetailsInputPane(factory, btnAccept::fire); + btnAccept.disableProperty().bind(((AccountDetailsInputPane) detailsPane).validProperty().not()); + } + detailsContainer.getChildren().add(detailsPane); + } + + private static class AccountDetailsInputPane extends GridPane { + + // ==== authlib-injector hyperlinks ==== + private static final String[] ALLOWED_LINKS = {"homepage", "register"}; + + private static List createHyperlinks(AuthlibInjectorServer server) { + if (server == null) { + return emptyList(); + } + + Map links = server.getLinks(); + List result = new ArrayList<>(); + for (String key : ALLOWED_LINKS) { + String value = links.get(key); + if (value != null) { + Hyperlink link = new Hyperlink(i18n("account.injector.link." + key)); + FXUtils.installSlowTooltip(link, value); + link.setOnAction(e -> FXUtils.openLink(value)); + result.add(link); + } + } + return unmodifiableList(result); + } + // ===== + + private final AccountFactory factory; + private @Nullable AuthlibInjectorServer server; + private @Nullable JFXComboBox cboServers; + private @Nullable JFXTextField txtUsername; + private @Nullable JFXPasswordField txtPassword; + private @Nullable JFXTextField txtUUID; + private final BooleanBinding valid; + + public AccountDetailsInputPane(AccountFactory factory, Runnable onAction) { + this.factory = factory; + + setVgap(22); + setHgap(15); + setAlignment(Pos.CENTER); + + ColumnConstraints col0 = new ColumnConstraints(); + col0.setMinWidth(USE_PREF_SIZE); + getColumnConstraints().add(col0); + ColumnConstraints col1 = new ColumnConstraints(); + col1.setHgrow(Priority.ALWAYS); + getColumnConstraints().add(col1); + + int rowIndex = 0; + + if (!IntegrityChecker.isOfficial() && !(factory instanceof OfflineAccountFactory)) { + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING); + hintPane.setSegment(i18n("unofficial.hint")); + GridPane.setColumnSpan(hintPane, 2); + add(hintPane, 0, rowIndex); + + rowIndex++; + } + + if (factory instanceof BoundAuthlibInjectorAccountFactory) { + this.server = ((BoundAuthlibInjectorAccountFactory) factory).getServer(); + + Label lblServers = new Label(i18n("account.injector.server")); + setHalignment(lblServers, HPos.LEFT); + add(lblServers, 0, rowIndex); + + Label lblServerName = new Label(this.server.getName()); + lblServerName.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(lblServerName, Priority.ALWAYS); + + HBox linksContainer = new HBox(); + linksContainer.setAlignment(Pos.CENTER); + linksContainer.getChildren().setAll(createHyperlinks(this.server)); + linksContainer.setMinWidth(USE_PREF_SIZE); + + HBox boxServers = new HBox(lblServerName, linksContainer); + boxServers.setAlignment(Pos.CENTER_LEFT); + add(boxServers, 1, rowIndex); + + rowIndex++; + } else if (factory instanceof AuthlibInjectorAccountFactory) { + Label lblServers = new Label(i18n("account.injector.server")); + setHalignment(lblServers, HPos.LEFT); + add(lblServers, 0, rowIndex); + + cboServers = new JFXComboBox<>(); + cboServers.setCellFactory(jfxListCellFactory(server -> new TwoLineListItem(server.getName(), server.getUrl()))); + cboServers.setConverter(stringConverter(AuthlibInjectorServer::getName)); + bindContent(cboServers.getItems(), config().getAuthlibInjectorServers()); + cboServers.getItems().addListener(onInvalidating( + () -> Platform.runLater( // the selection will not be updated as expected if we call it immediately + cboServers.getSelectionModel()::selectFirst))); + cboServers.getSelectionModel().selectFirst(); + cboServers.setPromptText(i18n("account.injector.empty")); + BooleanBinding noServers = createBooleanBinding(cboServers.getItems()::isEmpty, cboServers.getItems()); + classPropertyFor(cboServers, "jfx-combo-box-warning").bind(noServers); + classPropertyFor(cboServers, "jfx-combo-box").bind(noServers.not()); + HBox.setHgrow(cboServers, Priority.ALWAYS); + HBox.setMargin(cboServers, new Insets(0, 10, 0, 0)); + cboServers.setMaxWidth(Double.MAX_VALUE); + + HBox linksContainer = new HBox(); + linksContainer.setAlignment(Pos.CENTER); + onChangeAndOperate(cboServers.valueProperty(), server -> { + this.server = server; + linksContainer.getChildren().setAll(createHyperlinks(server)); + }); + linksContainer.setMinWidth(USE_PREF_SIZE); + + JFXButton btnAddServer = new JFXButton(); + btnAddServer.setGraphic(SVG.ADD.createIcon(Theme.blackFill(), 20)); + btnAddServer.getStyleClass().add("toggle-icon4"); + btnAddServer.setOnAction(e -> { + Controllers.dialog(new AddAuthlibInjectorServerPane()); + }); + + HBox boxServers = new HBox(cboServers, linksContainer, btnAddServer); + add(boxServers, 1, rowIndex); + + rowIndex++; + } + + if (factory.getLoginType().requiresUsername) { + Label lblUsername = new Label(i18n("account.username")); + setHalignment(lblUsername, HPos.LEFT); + add(lblUsername, 0, rowIndex); + + txtUsername = new JFXTextField(); + txtUsername.setValidators( + new RequiredValidator(), + new Validator(i18n("input.email"), username -> { + if (requiresEmailAsUsername()) { + return username.contains("@"); + } else { + return true; + } + })); + setValidateWhileTextChanged(txtUsername, true); + txtUsername.setOnAction(e -> onAction.run()); + add(txtUsername, 1, rowIndex); + + rowIndex++; + } + + if (factory.getLoginType().requiresPassword) { + Label lblPassword = new Label(i18n("account.password")); + setHalignment(lblPassword, HPos.LEFT); + add(lblPassword, 0, rowIndex); + + txtPassword = new JFXPasswordField(); + txtPassword.setValidators(new RequiredValidator()); + setValidateWhileTextChanged(txtPassword, true); + txtPassword.setOnAction(e -> onAction.run()); + add(txtPassword, 1, rowIndex); + + rowIndex++; + } + + if (factory instanceof OfflineAccountFactory) { + txtUsername.setPromptText(i18n("account.methods.offline.name.special_characters")); + FXUtils.installFastTooltip(txtUsername, i18n("account.methods.offline.name.special_characters")); + + JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase")); + purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL); + HBox linkPane = new HBox(purchaseLink); + GridPane.setColumnSpan(linkPane, 2); + add(linkPane, 0, rowIndex); + + rowIndex++; + + HBox box = new HBox(); + MenuUpDownButton advancedButton = new MenuUpDownButton(); + box.getChildren().setAll(advancedButton); + advancedButton.setText(i18n("settings.advanced")); + GridPane.setColumnSpan(box, 2); + add(box, 0, rowIndex); + + rowIndex++; + + Label lblUUID = new Label(i18n("account.methods.offline.uuid")); + lblUUID.managedProperty().bind(advancedButton.selectedProperty()); + lblUUID.visibleProperty().bind(advancedButton.selectedProperty()); + setHalignment(lblUUID, HPos.LEFT); + add(lblUUID, 0, rowIndex); + + txtUUID = new JFXTextField(); + txtUUID.managedProperty().bind(advancedButton.selectedProperty()); + txtUUID.visibleProperty().bind(advancedButton.selectedProperty()); + txtUUID.setValidators(new UUIDValidator()); + txtUUID.promptTextProperty().bind(BindingMapping.of(txtUsername.textProperty()).map(name -> OfflineAccountFactory.getUUIDFromUserName(name).toString())); + txtUUID.setOnAction(e -> onAction.run()); + add(txtUUID, 1, rowIndex); + + rowIndex++; + + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING); + hintPane.managedProperty().bind(advancedButton.selectedProperty()); + hintPane.visibleProperty().bind(advancedButton.selectedProperty()); + hintPane.setText(i18n("account.methods.offline.uuid.hint")); + GridPane.setColumnSpan(hintPane, 2); + add(hintPane, 0, rowIndex); + + rowIndex++; + } + + valid = new BooleanBinding() { + { + if (cboServers != null) + bind(cboServers.valueProperty()); + if (txtUsername != null) + bind(txtUsername.textProperty()); + if (txtPassword != null) + bind(txtPassword.textProperty()); + if (txtUUID != null) + bind(txtUUID.textProperty()); + } + + @Override + protected boolean computeValue() { + if (cboServers != null && cboServers.getValue() == null) + return false; + if (txtUsername != null && !txtUsername.validate()) + return false; + if (txtPassword != null && !txtPassword.validate()) + return false; + if (txtUUID != null && !txtUUID.validate()) + return false; + return true; + } + }; + } + + private boolean requiresEmailAsUsername() { + if ((factory instanceof AuthlibInjectorAccountFactory) && this.server != null) { + return !server.isNonEmailLogin(); + } + return false; + } + + public Object getAdditionalData() { + if (factory instanceof AuthlibInjectorAccountFactory) { + return getAuthServer(); + } else if (factory instanceof OfflineAccountFactory) { + UUID uuid = txtUUID == null ? null : StringUtils.isBlank(txtUUID.getText()) ? null : UUIDTypeAdapter.fromString(txtUUID.getText()); + return new OfflineAccountFactory.AdditionalData(uuid, null); + } else { + return null; + } + } + + public @Nullable AuthlibInjectorServer getAuthServer() { + return this.server; + } + + public @Nullable String getUsername() { + return txtUsername == null ? null : txtUsername.getText(); + } + + public @Nullable String getPassword() { + return txtPassword == null ? null : txtPassword.getText(); + } + + public BooleanBinding validProperty() { + return valid; + } + + public void focus() { + if (txtUsername != null) { + txtUsername.requestFocus(); + } + } + } + + private static class DialogCharacterSelector extends BorderPane implements CharacterSelector { + + private final AdvancedListBox listBox = new AdvancedListBox(); + private final JFXButton cancel = new JFXButton(); + + private final CountDownLatch latch = new CountDownLatch(1); + private GameProfile selectedProfile = null; + + public DialogCharacterSelector() { + setStyle("-fx-padding: 8px;"); + + cancel.setText(i18n("button.cancel")); + StackPane.setAlignment(cancel, Pos.BOTTOM_RIGHT); + cancel.setOnAction(e -> latch.countDown()); + + listBox.startCategory(i18n("account.choose").toUpperCase(Locale.ROOT)); + + setCenter(listBox); + + HBox hbox = new HBox(); + hbox.setAlignment(Pos.CENTER_RIGHT); + hbox.getChildren().add(cancel); + setBottom(hbox); + + onEscPressed(this, cancel::fire); + } + + @Override + public GameProfile select(YggdrasilService service, List profiles) throws NoSelectedCharacterException { + Platform.runLater(() -> { + for (GameProfile profile : profiles) { + Canvas portraitCanvas = new Canvas(32, 32); + TexturesLoader.bindAvatar(portraitCanvas, service, profile.getId()); + + IconedItem accountItem = new IconedItem(portraitCanvas, profile.getName()); + FXUtils.onClicked(accountItem, () -> { + selectedProfile = profile; + latch.countDown(); + }); + listBox.add(accountItem); + } + Controllers.dialog(this); + }); + + try { + latch.await(); + + if (selectedProfile == null) + throw new NoSelectedCharacterException(); + + return selectedProfile; + } catch (InterruptedException ignored) { + throw new NoSelectedCharacterException(); + } finally { + Platform.runLater(() -> fireEvent(new DialogCloseEvent())); + } + } + } + + @Override + public void onDialogShown() { + if (detailsPane instanceof AccountDetailsInputPane) { + ((AccountDetailsInputPane) detailsPane).focus(); + } + } + + private static class UUIDValidator extends ValidatorBase { + + public UUIDValidator() { + this(i18n("account.methods.offline.uuid.malformed")); + } + + public UUIDValidator(@NamedArg("message") String message) { + super(message); + } + + @Override + protected void eval() { + if (srcControl.get() instanceof TextInputControl) { + evalTextInputField(); + } + } + + private void evalTextInputField() { + TextInputControl textField = ((TextInputControl) srcControl.get()); + if (StringUtils.isBlank(textField.getText())) { + hasErrors.set(false); + return; + } + + try { + UUIDTypeAdapter.fromString(textField.getText()); + hasErrors.set(false); + } catch (IllegalArgumentException ignored) { + hasErrors.set(true); + } + } + } + + private static final String MICROSOFT_ACCOUNT_EDIT_PROFILE_URL = "https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a"; +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OAuthAccountLoginDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OAuthAccountLoginDialog.java new file mode 100644 index 0000000000..36780c3d47 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OAuthAccountLoginDialog.java @@ -0,0 +1,109 @@ +package org.jackhuang.hmcl.ui.account; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import org.jackhuang.hmcl.auth.AuthInfo; +import org.jackhuang.hmcl.auth.OAuthAccount; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; +import org.jackhuang.hmcl.game.OAuthServer; +import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.WeakListenerHolder; +import org.jackhuang.hmcl.ui.construct.DialogPane; +import org.jackhuang.hmcl.ui.construct.HintPane; +import org.jackhuang.hmcl.ui.construct.JFXHyperlink; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane; + +import java.util.function.Consumer; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public class OAuthAccountLoginDialog extends DialogPane { + private final OAuthAccount account; + private final Consumer success; + private final Runnable failed; + private final ObjectProperty deviceCode = new SimpleObjectProperty<>(); + + private final WeakListenerHolder holder = new WeakListenerHolder(); + + public OAuthAccountLoginDialog(OAuthAccount account, Consumer success, Runnable failed) { + this.account = account; + this.success = success; + this.failed = failed; + + setTitle(i18n("account.login.refresh")); + + VBox vbox = new VBox(8); + Label usernameLabel = new Label(account.getUsername()); + + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO); + FXUtils.onChangeAndOperate(deviceCode, deviceCode -> { + if (deviceCode != null) { + FXUtils.copyText(deviceCode.getUserCode()); + hintPane.setSegment( + "" + i18n("account.login.refresh.microsoft.hint") + "\n" + + i18n("account.methods.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri()) + ); + } else { + hintPane.setSegment( + "" + i18n("account.login.refresh.microsoft.hint") + "\n" + + i18n("account.methods.microsoft.hint") + ); + } + }); + FXUtils.onClicked(hintPane, () -> { + if (deviceCode.get() != null) { + FXUtils.copyText(deviceCode.get().getUserCode()); + } + }); + + HBox box = new HBox(8); + JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth")); + birthLink.setOnAction(e -> FXUtils.openLink("https://support.microsoft.com/account-billing/how-to-change-a-birth-date-on-a-microsoft-account-837badbc-999e-54d2-2617-d19206b9540a")); + JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile")); + profileLink.setOnAction(e -> FXUtils.openLink("https://account.live.com/editprof.aspx")); + JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase")); + purchaseLink.setOnAction(e -> FXUtils.openLink(YggdrasilService.PURCHASE_URL)); + box.getChildren().setAll(profileLink, birthLink, purchaseLink); + GridPane.setColumnSpan(box, 2); + + vbox.getChildren().setAll(usernameLabel, hintPane, box); + setBody(vbox); + + holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(this::onGrantDeviceCode)); + } + + private void onGrantDeviceCode(OAuthServer.GrantDeviceCodeEvent event) { + FXUtils.runInFX(() -> { + deviceCode.set(event); + }); + } + + @Override + protected void onAccept() { + setLoading(); + Task.supplyAsync(account::logInWhenCredentialsExpired) + .whenComplete(Schedulers.javafx(), (authInfo, exception) -> { + if (exception == null) { + success.accept(authInfo); + onSuccess(); + } else { + LOG.info("Failed to login when credentials expired: " + account, exception); + onFailure(Accounts.localizeErrorMessage(exception)); + } + }).start(); + } + + @Override + protected void onCancel() { + failed.run(); + super.onCancel(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java new file mode 100644 index 0000000000..ce6bc7ce33 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java @@ -0,0 +1,216 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.account; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXComboBox; +import com.jfoenix.controls.JFXDialogLayout; +import com.jfoenix.controls.JFXTextField; +import javafx.application.Platform; +import javafx.beans.InvalidationListener; +import javafx.geometry.Insets; +import javafx.scene.control.Label; +import javafx.scene.input.DragEvent; +import javafx.scene.input.TransferMode; +import javafx.scene.layout.*; +import org.jackhuang.hmcl.ui.skin.SkinCanvas; +import org.jackhuang.hmcl.ui.skin.animation.SkinAniRunning; +import org.jackhuang.hmcl.ui.skin.animation.SkinAniWavingArms; +import org.jackhuang.hmcl.auth.offline.OfflineAccount; +import org.jackhuang.hmcl.auth.offline.Skin; +import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; +import org.jackhuang.hmcl.game.TexturesLoader; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.construct.*; +import org.jackhuang.hmcl.util.io.FileUtils; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.UUID; + +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public class OfflineAccountSkinPane extends StackPane { + private final OfflineAccount account; + + private final MultiFileItem skinItem = new MultiFileItem<>(); + private final JFXTextField cslApiField = new JFXTextField(); + private final JFXComboBox modelCombobox = new JFXComboBox<>(); + private final FileSelector skinSelector = new FileSelector(); + private final FileSelector capeSelector = new FileSelector(); + + private final InvalidationListener skinBinding; + + public OfflineAccountSkinPane(OfflineAccount account) { + this.account = account; + + getStyleClass().add("skin-pane"); + + JFXDialogLayout layout = new JFXDialogLayout(); + getChildren().setAll(layout); + layout.setHeading(new Label(i18n("account.skin"))); + + BorderPane pane = new BorderPane(); + + SkinCanvas canvas = new SkinCanvas(TexturesLoader.getDefaultSkinImage(), 300, 300, true); + StackPane canvasPane = new StackPane(canvas); + canvasPane.setPrefWidth(300); + canvasPane.setPrefHeight(300); + pane.setCenter(canvas); + canvas.getAnimationPlayer().addSkinAnimation(new SkinAniWavingArms(100, 2000, 7.5, canvas), new SkinAniRunning(100, 100, 30, canvas)); + canvas.enableRotation(.5); + + canvas.addEventHandler(DragEvent.DRAG_OVER, e -> { + if (e.getDragboard().hasFiles()) { + Path file = e.getDragboard().getFiles().get(0).toPath(); + if (FileUtils.getName(file).endsWith(".png")) + e.acceptTransferModes(TransferMode.COPY); + } + }); + canvas.addEventHandler(DragEvent.DRAG_DROPPED, e -> { + if (e.isAccepted()) { + Path skin = e.getDragboard().getFiles().get(0).toPath(); + Platform.runLater(() -> { + skinSelector.setValue(FileUtils.getAbsolutePath(skin)); + skinItem.setSelectedData(Skin.Type.LOCAL_FILE); + }); + } + }); + + StackPane skinOptionPane = new StackPane(); + skinOptionPane.setMaxWidth(300); + VBox optionPane = new VBox(skinItem, skinOptionPane); + pane.setRight(optionPane); + + skinSelector.maxWidthProperty().bind(skinOptionPane.maxWidthProperty().multiply(0.7)); + capeSelector.maxWidthProperty().bind(skinOptionPane.maxWidthProperty().multiply(0.7)); + + layout.setBody(pane); + + cslApiField.setPromptText(i18n("account.skin.type.csl_api.location.hint")); + cslApiField.setValidators(new URLValidator()); + + skinItem.loadChildren(Arrays.asList( + new MultiFileItem.Option<>(i18n("message.default"), Skin.Type.DEFAULT), + new MultiFileItem.Option<>(i18n("account.skin.type.steve"), Skin.Type.STEVE), + new MultiFileItem.Option<>(i18n("account.skin.type.alex"), Skin.Type.ALEX), + new MultiFileItem.Option<>(i18n("account.skin.type.local_file"), Skin.Type.LOCAL_FILE), + new MultiFileItem.Option<>(i18n("account.skin.type.little_skin"), Skin.Type.LITTLE_SKIN), + new MultiFileItem.Option<>(i18n("account.skin.type.csl_api"), Skin.Type.CUSTOM_SKIN_LOADER_API) + )); + + modelCombobox.setConverter(stringConverter(model -> i18n("account.skin.model." + model.modelName))); + modelCombobox.getItems().setAll(TextureModel.WIDE, TextureModel.SLIM); + + if (account.getSkin() == null) { + skinItem.setSelectedData(Skin.Type.DEFAULT); + modelCombobox.setValue(TextureModel.WIDE); + } else { + skinItem.setSelectedData(account.getSkin().getType()); + cslApiField.setText(account.getSkin().getCslApi()); + modelCombobox.setValue(account.getSkin().getTextureModel()); + skinSelector.setValue(account.getSkin().getLocalSkinPath()); + capeSelector.setValue(account.getSkin().getLocalCapePath()); + } + + skinBinding = FXUtils.observeWeak(() -> { + getSkin().load(account.getUsername()) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception != null) { + LOG.warning("Failed to load skin", exception); + Controllers.showToast(i18n("message.failed")); + } else { + UUID uuid = this.account.getUUID(); + if (result == null || result.getSkin() == null && result.getCape() == null) { + canvas.updateSkin( + TexturesLoader.getDefaultSkin(uuid).getImage(), + TexturesLoader.getDefaultModel(uuid) == TextureModel.SLIM, + null + ); + return; + } + canvas.updateSkin( + result.getSkin() != null ? result.getSkin().getImage() : TexturesLoader.getDefaultSkin(uuid).getImage(), + result.getModel() == TextureModel.SLIM, + result.getCape() != null ? result.getCape().getImage() : null); + } + }).start(); + }, skinItem.selectedDataProperty(), cslApiField.textProperty(), modelCombobox.valueProperty(), skinSelector.valueProperty(), capeSelector.valueProperty()); + + FXUtils.onChangeAndOperate(skinItem.selectedDataProperty(), selectedData -> { + GridPane gridPane = new GridPane(); + gridPane.setPadding(new Insets(0, 0, 0, 10)); + gridPane.setHgap(16); + gridPane.setVgap(8); + gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing()); + + switch (selectedData) { + case DEFAULT: + case STEVE: + case ALEX: + break; + case LITTLE_SKIN: + HintPane hint = new HintPane(MessageDialogPane.MessageType.INFO); + hint.setText(i18n("account.skin.type.little_skin.hint")); + gridPane.addRow(0, hint); + break; + case LOCAL_FILE: + gridPane.addRow(0, new Label(i18n("account.skin.model")), modelCombobox); + gridPane.addRow(1, new Label(i18n("account.skin")), skinSelector); + gridPane.addRow(2, new Label(i18n("account.cape")), capeSelector); + break; + case CUSTOM_SKIN_LOADER_API: + gridPane.addRow(0, new Label(i18n("account.skin.type.csl_api.location")), cslApiField); + break; + } + + skinOptionPane.getChildren().setAll(gridPane); + }); + + JFXButton acceptButton = new JFXButton(i18n("button.ok")); + acceptButton.getStyleClass().add("dialog-accept"); + acceptButton.setOnAction(e -> { + account.setSkin(getSkin()); + fireEvent(new DialogCloseEvent()); + }); + + JFXHyperlink littleSkinLink = new JFXHyperlink(i18n("account.skin.type.little_skin")); + littleSkinLink.setOnAction(e -> FXUtils.openLink("https://littleskin.cn/")); + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.getStyleClass().add("dialog-cancel"); + cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); + onEscPressed(this, cancelButton::fire); + + layout.setActions(littleSkinLink, acceptButton, cancelButton); + } + + private Skin getSkin() { + Skin.Type type = skinItem.getSelectedData(); + if (type == Skin.Type.LOCAL_FILE) { + return new Skin(type, cslApiField.getText(), modelCombobox.getValue(), skinSelector.getValue(), capeSelector.getValue()); + } else { + String cslApi = type == Skin.Type.CUSTOM_SKIN_LOADER_API ? cslApiField.getText() : null; + return new Skin(type, cslApi, null, null, null); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationHandler.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationHandler.java index 9d8414165a..b3c46a105c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationHandler.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationHandler.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,7 +23,10 @@ public interface AnimationHandler { Duration getDuration(); + Pane getCurrentRoot(); + Node getPreviousNode(); + Node getCurrentNode(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationProducer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationProducer.java index 7f9059feba..10839c6bbe 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationProducer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationProducer.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.ui.animation; import javafx.animation.KeyFrame; +import org.jetbrains.annotations.Nullable; import java.util.List; @@ -25,4 +26,6 @@ public interface AnimationProducer { void init(AnimationHandler handler); List animate(AnimationHandler handler); + + @Nullable AnimationProducer opposite(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationUtils.java new file mode 100644 index 0000000000..7dca68b049 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationUtils.java @@ -0,0 +1,49 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.animation; + +import org.jackhuang.hmcl.setting.ConfigHolder; +import org.jackhuang.hmcl.util.platform.OperatingSystem; + +/** + * @author Glavo + */ +public final class AnimationUtils { + + private AnimationUtils() { + } + + /** + * Trigger initialization of this class. + * Should be called from {@link org.jackhuang.hmcl.setting.Settings#init()}. + */ + @SuppressWarnings("JavadocReference") + public static void init() { + } + + private static final boolean ENABLED = !ConfigHolder.config().isAnimationDisabled(); + private static final boolean PLAY_WINDOW_ANIMATION = ENABLED && !OperatingSystem.CURRENT_OS.isLinuxOrBSD(); + + public static boolean isAnimationEnabled() { + return ENABLED; + } + + public static boolean playWindowAnimation() { + return PLAY_WINDOW_ANIMATION; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java index 6c53e003d1..281ac618db 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,144 +21,292 @@ import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.util.Duration; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jetbrains.annotations.Nullable; import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; - -public enum ContainerAnimations { - NONE(c -> { - c.getPreviousNode().setTranslateX(0); - c.getPreviousNode().setTranslateY(0); - c.getPreviousNode().setScaleX(1); - c.getPreviousNode().setScaleY(1); - c.getPreviousNode().setOpacity(1); - c.getCurrentNode().setTranslateX(0); - c.getCurrentNode().setTranslateY(0); - c.getCurrentNode().setScaleX(1); - c.getCurrentNode().setScaleY(1); - c.getCurrentNode().setOpacity(1); - }, c -> Collections.emptyList()), + +public enum ContainerAnimations implements AnimationProducer { + NONE { + @Override + public void init(AnimationHandler c) { + c.getPreviousNode().setTranslateX(0); + c.getPreviousNode().setTranslateY(0); + c.getPreviousNode().setScaleX(1); + c.getPreviousNode().setScaleY(1); + c.getPreviousNode().setOpacity(1); + c.getCurrentNode().setTranslateX(0); + c.getCurrentNode().setTranslateY(0); + c.getCurrentNode().setScaleX(1); + c.getCurrentNode().setScaleY(1); + c.getCurrentNode().setOpacity(1); + } + + @Override + public List animate(AnimationHandler c) { + return Collections.emptyList(); + } + }, + /** * A fade between the old and new view */ - FADE(c -> { - c.getPreviousNode().setTranslateX(0); - c.getPreviousNode().setTranslateY(0); - c.getPreviousNode().setScaleX(1); - c.getPreviousNode().setScaleY(1); - c.getPreviousNode().setOpacity(1); - c.getCurrentNode().setTranslateX(0); - c.getCurrentNode().setTranslateY(0); - c.getCurrentNode().setScaleX(1); - c.getCurrentNode().setScaleY(1); - c.getCurrentNode().setOpacity(0); - }, c -> - Arrays.asList(new KeyFrame(Duration.ZERO, + FADE { + @Override + public void init(AnimationHandler c) { + c.getPreviousNode().setTranslateX(0); + c.getPreviousNode().setTranslateY(0); + c.getPreviousNode().setScaleX(1); + c.getPreviousNode().setScaleY(1); + c.getPreviousNode().setOpacity(1); + c.getCurrentNode().setTranslateX(0); + c.getCurrentNode().setTranslateY(0); + c.getCurrentNode().setScaleX(1); + c.getCurrentNode().setScaleY(1); + c.getCurrentNode().setOpacity(0); + } + + @Override + public List animate(AnimationHandler c) { + return Arrays.asList(new KeyFrame(Duration.ZERO, new KeyValue(c.getPreviousNode().opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(c.getCurrentNode().opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(c.getDuration(), new KeyValue(c.getPreviousNode().opacityProperty(), 0, Interpolator.EASE_BOTH), - new KeyValue(c.getCurrentNode().opacityProperty(), 1, Interpolator.EASE_BOTH)))), + new KeyValue(c.getCurrentNode().opacityProperty(), 1, Interpolator.EASE_BOTH))); + } + }, + + /** + * A fade between the old and new view + */ + FADE_IN { + @Override + public void init(AnimationHandler c) { + c.getCurrentNode().setTranslateX(0); + c.getCurrentNode().setTranslateY(0); + c.getCurrentNode().setScaleX(1); + c.getCurrentNode().setScaleY(1); + c.getCurrentNode().setOpacity(0); + } + + @Override + public List animate(AnimationHandler c) { + return Arrays.asList(new KeyFrame(Duration.ZERO, + new KeyValue(c.getCurrentNode().opacityProperty(), 0, FXUtils.SINE)), + new KeyFrame(c.getDuration(), + new KeyValue(c.getCurrentNode().opacityProperty(), 1, FXUtils.SINE))); + } + }, + + /** + * A fade between the old and new view + */ + FADE_OUT { + @Override + public void init(AnimationHandler c) { + c.getCurrentNode().setTranslateX(0); + c.getCurrentNode().setTranslateY(0); + c.getCurrentNode().setScaleX(1); + c.getCurrentNode().setScaleY(1); + c.getCurrentNode().setOpacity(1); + } + + @Override + public List animate(AnimationHandler c) { + return Arrays.asList(new KeyFrame(Duration.ZERO, + new KeyValue(c.getCurrentNode().opacityProperty(), 1, FXUtils.SINE)), + new KeyFrame(c.getDuration(), + new KeyValue(c.getCurrentNode().opacityProperty(), 0, FXUtils.SINE))); + } + }, /** * A zoom effect */ - ZOOM_IN(c -> { - c.getPreviousNode().setTranslateX(0); - c.getPreviousNode().setTranslateY(0); - c.getPreviousNode().setScaleX(1); - c.getPreviousNode().setScaleY(1); - c.getPreviousNode().setOpacity(1); - c.getCurrentNode().setTranslateX(0); - c.getCurrentNode().setTranslateY(0); - }, c -> - Arrays.asList(new KeyFrame(Duration.ZERO, + ZOOM_IN { + @Override + public void init(AnimationHandler c) { + c.getPreviousNode().setTranslateX(0); + c.getPreviousNode().setTranslateY(0); + c.getPreviousNode().setScaleX(1); + c.getPreviousNode().setScaleY(1); + c.getPreviousNode().setOpacity(1); + c.getCurrentNode().setTranslateX(0); + c.getCurrentNode().setTranslateY(0); + } + + @Override + public List animate(AnimationHandler c) { + return Arrays.asList(new KeyFrame(Duration.ZERO, new KeyValue(c.getPreviousNode().scaleXProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(c.getPreviousNode().scaleYProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(c.getPreviousNode().opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(c.getDuration(), new KeyValue(c.getPreviousNode().scaleXProperty(), 4, Interpolator.EASE_BOTH), new KeyValue(c.getPreviousNode().scaleYProperty(), 4, Interpolator.EASE_BOTH), - new KeyValue(c.getPreviousNode().opacityProperty(), 0, Interpolator.EASE_BOTH)))), + new KeyValue(c.getPreviousNode().opacityProperty(), 0, Interpolator.EASE_BOTH))); + } + }, /** * A zoom effect */ - ZOOM_OUT(c -> { - c.getPreviousNode().setTranslateX(0); - c.getPreviousNode().setTranslateY(0); - c.getPreviousNode().setScaleX(1); - c.getPreviousNode().setScaleY(1); - c.getPreviousNode().setOpacity(1); - c.getCurrentNode().setTranslateX(0); - c.getCurrentNode().setTranslateY(0); - }, c -> - (Arrays.asList(new KeyFrame(Duration.ZERO, + ZOOM_OUT { + @Override + public void init(AnimationHandler c) { + c.getPreviousNode().setTranslateX(0); + c.getPreviousNode().setTranslateY(0); + c.getPreviousNode().setScaleX(1); + c.getPreviousNode().setScaleY(1); + c.getPreviousNode().setOpacity(1); + c.getCurrentNode().setTranslateX(0); + c.getCurrentNode().setTranslateY(0); + } + + @Override + public List animate(AnimationHandler c) { + return Arrays.asList(new KeyFrame(Duration.ZERO, new KeyValue(c.getPreviousNode().scaleXProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(c.getPreviousNode().scaleYProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(c.getPreviousNode().opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(c.getDuration(), new KeyValue(c.getPreviousNode().scaleXProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(c.getPreviousNode().scaleYProperty(), 0, Interpolator.EASE_BOTH), - new KeyValue(c.getPreviousNode().opacityProperty(), 0, Interpolator.EASE_BOTH))))), + new KeyValue(c.getPreviousNode().opacityProperty(), 0, Interpolator.EASE_BOTH))); + } + }, /** * A swipe effect */ - SWIPE_LEFT(c -> { - c.getPreviousNode().setScaleX(1); - c.getPreviousNode().setScaleY(1); - c.getPreviousNode().setOpacity(0); - c.getPreviousNode().setTranslateX(0); - c.getCurrentNode().setScaleX(1); - c.getCurrentNode().setScaleY(1); - c.getCurrentNode().setOpacity(1); - c.getCurrentNode().setTranslateX(c.getCurrentRoot().getWidth()); - }, c -> - Arrays.asList(new KeyFrame(Duration.ZERO, + SWIPE_LEFT { + @Override + public void init(AnimationHandler c) { + c.getPreviousNode().setScaleX(1); + c.getPreviousNode().setScaleY(1); + c.getPreviousNode().setOpacity(0); + c.getPreviousNode().setTranslateX(0); + c.getCurrentNode().setScaleX(1); + c.getCurrentNode().setScaleY(1); + c.getCurrentNode().setOpacity(1); + c.getCurrentNode().setTranslateX(c.getCurrentRoot().getWidth()); + } + + @Override + public List animate(AnimationHandler c) { + return Arrays.asList(new KeyFrame(Duration.ZERO, new KeyValue(c.getCurrentNode().translateXProperty(), c.getCurrentRoot().getWidth(), Interpolator.EASE_BOTH), new KeyValue(c.getPreviousNode().translateXProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(c.getDuration(), new KeyValue(c.getCurrentNode().translateXProperty(), 0, Interpolator.EASE_BOTH), - new KeyValue(c.getPreviousNode().translateXProperty(), -c.getCurrentRoot().getWidth(), Interpolator.EASE_BOTH)))), + new KeyValue(c.getPreviousNode().translateXProperty(), -c.getCurrentRoot().getWidth(), Interpolator.EASE_BOTH))); + } + }, /** * A swipe effect */ - SWIPE_RIGHT(c -> { - c.getPreviousNode().setScaleX(1); - c.getPreviousNode().setScaleY(1); - c.getPreviousNode().setOpacity(0); - c.getPreviousNode().setTranslateX(0); - c.getCurrentNode().setScaleX(1); - c.getCurrentNode().setScaleY(1); - c.getCurrentNode().setOpacity(1); - c.getCurrentNode().setTranslateX(-c.getCurrentRoot().getWidth()); - }, c -> - Arrays.asList(new KeyFrame(Duration.ZERO, + SWIPE_RIGHT { + @Override + public void init(AnimationHandler c) { + c.getPreviousNode().setScaleX(1); + c.getPreviousNode().setScaleY(1); + c.getPreviousNode().setOpacity(0); + c.getPreviousNode().setTranslateX(0); + c.getCurrentNode().setScaleX(1); + c.getCurrentNode().setScaleY(1); + c.getCurrentNode().setOpacity(1); + c.getCurrentNode().setTranslateX(-c.getCurrentRoot().getWidth()); + } + + @Override + public List animate(AnimationHandler c) { + return Arrays.asList(new KeyFrame(Duration.ZERO, new KeyValue(c.getCurrentNode().translateXProperty(), -c.getCurrentRoot().getWidth(), Interpolator.EASE_BOTH), new KeyValue(c.getPreviousNode().translateXProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(c.getDuration(), new KeyValue(c.getCurrentNode().translateXProperty(), 0, Interpolator.EASE_BOTH), - new KeyValue(c.getPreviousNode().translateXProperty(), c.getCurrentRoot().getWidth(), Interpolator.EASE_BOTH)))); - - private final AnimationProducer animationProducer; - - ContainerAnimations(Consumer init, Function> animationProducer) { - this.animationProducer = new AnimationProducer() { - @Override - public void init(AnimationHandler handler) { - init.accept(handler); - } - - @Override - public List animate(AnimationHandler handler) { - return animationProducer.apply(handler); - } - }; + new KeyValue(c.getPreviousNode().translateXProperty(), c.getCurrentRoot().getWidth(), Interpolator.EASE_BOTH))); + } + }, + + SWIPE_LEFT_FADE_SHORT { + @Override + public void init(AnimationHandler c) { + c.getPreviousNode().setScaleX(1); + c.getPreviousNode().setScaleY(1); + c.getPreviousNode().setOpacity(0); + c.getPreviousNode().setTranslateX(0); + c.getCurrentNode().setScaleX(1); + c.getCurrentNode().setScaleY(1); + c.getCurrentNode().setOpacity(1); + c.getCurrentNode().setTranslateX(c.getCurrentRoot().getWidth()); + } + + @Override + public List animate(AnimationHandler c) { + return Arrays.asList(new KeyFrame(Duration.ZERO, + new KeyValue(c.getCurrentNode().translateXProperty(), 50, Interpolator.EASE_BOTH), + new KeyValue(c.getPreviousNode().translateXProperty(), 0, Interpolator.EASE_BOTH), + new KeyValue(c.getCurrentNode().opacityProperty(), 0, Interpolator.EASE_BOTH), + new KeyValue(c.getPreviousNode().opacityProperty(), 1, Interpolator.EASE_BOTH)), + new KeyFrame(c.getDuration(), + new KeyValue(c.getCurrentNode().translateXProperty(), 0, Interpolator.EASE_BOTH), + new KeyValue(c.getPreviousNode().translateXProperty(), -50, Interpolator.EASE_BOTH), + new KeyValue(c.getCurrentNode().opacityProperty(), 1, Interpolator.EASE_BOTH), + new KeyValue(c.getPreviousNode().opacityProperty(), 0, Interpolator.EASE_BOTH))); + } + }, + + SWIPE_RIGHT_FADE_SHORT { + @Override + public void init(AnimationHandler c) { + c.getPreviousNode().setScaleX(1); + c.getPreviousNode().setScaleY(1); + c.getPreviousNode().setOpacity(0); + c.getPreviousNode().setTranslateX(0); + c.getCurrentNode().setScaleX(1); + c.getCurrentNode().setScaleY(1); + c.getCurrentNode().setOpacity(1); + c.getCurrentNode().setTranslateX(c.getCurrentRoot().getWidth()); + } + + @Override + public List animate(AnimationHandler c) { + return Arrays.asList(new KeyFrame(Duration.ZERO, + new KeyValue(c.getCurrentNode().translateXProperty(), -50, Interpolator.EASE_BOTH), + new KeyValue(c.getPreviousNode().translateXProperty(), 0, Interpolator.EASE_BOTH), + new KeyValue(c.getCurrentNode().opacityProperty(), 0, Interpolator.EASE_BOTH), + new KeyValue(c.getPreviousNode().opacityProperty(), 1, Interpolator.EASE_BOTH)), + new KeyFrame(c.getDuration(), + new KeyValue(c.getCurrentNode().translateXProperty(), 0, Interpolator.EASE_BOTH), + new KeyValue(c.getPreviousNode().translateXProperty(), 50, Interpolator.EASE_BOTH), + new KeyValue(c.getCurrentNode().opacityProperty(), 1, Interpolator.EASE_BOTH), + new KeyValue(c.getPreviousNode().opacityProperty(), 0, Interpolator.EASE_BOTH))); + } + }; + + private ContainerAnimations opposite; + + static { + NONE.opposite = NONE; + FADE.opposite = FADE; + SWIPE_LEFT.opposite = SWIPE_RIGHT; + SWIPE_RIGHT.opposite = SWIPE_LEFT; + FADE_IN.opposite = FADE_OUT; + FADE_OUT.opposite = FADE_IN; + ZOOM_IN.opposite = ZOOM_OUT; + ZOOM_OUT.opposite = ZOOM_IN; } - public AnimationProducer getAnimationProducer() { - return animationProducer; + @Override + public abstract void init(AnimationHandler handler); + + @Override + public abstract List animate(AnimationHandler handler); + + @Override + public @Nullable ContainerAnimations opposite() { + return opposite; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionHandler.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java similarity index 50% rename from HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionHandler.java rename to HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java index 459487637f..f3ebe322f2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionHandler.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,33 +17,21 @@ */ package org.jackhuang.hmcl.ui.animation; -import javafx.animation.KeyFrame; import javafx.animation.Timeline; import javafx.application.Platform; import javafx.scene.Node; -import javafx.scene.Parent; import javafx.scene.layout.StackPane; -import javafx.scene.shape.Rectangle; import javafx.util.Duration; +import org.jackhuang.hmcl.ui.FXUtils; + +public class TransitionPane extends StackPane implements AnimationHandler { + private static final Duration DEFAULT_DURATION = Duration.millis(200); -public final class TransitionHandler implements AnimationHandler { - private final StackPane view; - private Timeline animation; private Duration duration; private Node previousNode, currentNode; - /** - * @param view A stack pane that contains another control that is {@link Parent} - */ - public TransitionHandler(StackPane view) { - this.view = view; - currentNode = view.getChildren().stream().findFirst().orElse(null); - - // prevent content overflow - Rectangle clip = new Rectangle(); - clip.widthProperty().bind(view.widthProperty()); - clip.heightProperty().bind(view.heightProperty()); - view.setClip(clip); + public TransitionPane() { + FXUtils.setOverflowHidden(this); } @Override @@ -58,7 +46,7 @@ public Node getCurrentNode() { @Override public StackPane getCurrentRoot() { - return view; + return this; } @Override @@ -67,50 +55,60 @@ public Duration getDuration() { } public void setContent(Node newView, AnimationProducer transition) { - setContent(newView, transition, Duration.millis(320)); + setContent(newView, transition, DEFAULT_DURATION); } public void setContent(Node newView, AnimationProducer transition, Duration duration) { this.duration = duration; - Timeline prev = animation; - if (prev != null) - prev.stop(); - updateContent(newView); - transition.init(this); - - // runLater or "init" will not work - Platform.runLater(() -> { - Timeline nowAnimation = new Timeline(); - nowAnimation.getKeyFrames().addAll(transition.animate(this)); - nowAnimation.getKeyFrames().add(new KeyFrame(duration, e -> { - view.setMouseTransparent(false); - view.getChildren().remove(previousNode); - })); - nowAnimation.play(); - animation = nowAnimation; - }); + if (previousNode == EMPTY_PANE) { + getChildren().setAll(newView); + return; + } + + if (AnimationUtils.isAnimationEnabled() && transition != ContainerAnimations.NONE) { + setMouseTransparent(true); + transition.init(this); + + // runLater or "init" will not work + Platform.runLater(() -> { + Timeline newAnimation = new Timeline(); + newAnimation.getKeyFrames().setAll(transition.animate(this)); + newAnimation.setOnFinished(e -> { + setMouseTransparent(false); + getChildren().remove(previousNode); + }); + FXUtils.playAnimation(this, "transition_pane", newAnimation); + }); + } else { + getChildren().remove(previousNode); + } } private void updateContent(Node newView) { - if (view.getWidth() > 0 && view.getHeight() > 0) { + if (getWidth() > 0 && getHeight() > 0) { previousNode = currentNode; - if (previousNode == null) - previousNode = EMPTY_PANE; + if (previousNode == null) { + if (getChildren().isEmpty()) + previousNode = EMPTY_PANE; + else + previousNode = getChildren().get(0); + } } else previousNode = EMPTY_PANE; if (previousNode == newView) previousNode = EMPTY_PANE; - view.setMouseTransparent(true); - currentNode = newView; - view.getChildren().setAll(previousNode, currentNode); + getChildren().setAll(previousNode, currentNode); } - private final StackPane EMPTY_PANE = new StackPane(); + private final EmptyPane EMPTY_PANE = new EmptyPane(); + + public static class EmptyPane extends StackPane { + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java index 7783443de7..d47ba4e64b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,10 +20,15 @@ import javafx.collections.ObservableList; import javafx.scene.Node; import javafx.scene.control.ScrollPane; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.Pane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.versions.VersionPage; + +import java.util.function.Consumer; public class AdvancedListBox extends ScrollPane { private final VBox container = new VBox(); @@ -36,9 +41,16 @@ public class AdvancedListBox extends ScrollPane { setFitToHeight(true); setFitToWidth(true); setHbarPolicy(ScrollBarPolicy.NEVER); + setVbarPolicy(ScrollBarPolicy.NEVER); - container.setSpacing(5); container.getStyleClass().add("advanced-list-box-content"); + + this.addEventFilter(MouseEvent.MOUSE_ENTERED, event -> { + if (container.getHeight() > getHeight()) + setVbarPolicy(ScrollBarPolicy.AS_NEEDED); + }); + this.addEventFilter(MouseEvent.MOUSE_EXITED, + event -> setVbarPolicy(ScrollBarPolicy.NEVER)); } public AdvancedListBox add(Node child) { @@ -53,22 +65,70 @@ public AdvancedListBox add(Node child) { return this; } - public AdvancedListBox remove(Node child) { - if (child instanceof Pane) - container.getChildren().remove(child); + private AdvancedListItem createNavigationDrawerItem(String title, SVG leftGraphic) { + AdvancedListItem item = new AdvancedListItem(); + item.getStyleClass().add("navigation-drawer-item"); + item.setActionButtonVisible(false); + item.setTitle(title); + if (leftGraphic != null) { + item.setLeftGraphic(VersionPage.wrap(leftGraphic)); + } + return item; + } + + public AdvancedListBox addNavigationDrawerItem(String title, SVG leftGraphic, Runnable onAction) { + return addNavigationDrawerItem(title, leftGraphic, onAction, null); + } + + public AdvancedListBox addNavigationDrawerItem(String title, SVG leftGraphic, Runnable onAction, Consumer initializer) { + AdvancedListItem item = createNavigationDrawerItem(title, leftGraphic); + if (onAction != null) { + item.setOnAction(e -> onAction.run()); + } + if (initializer != null) { + initializer.accept(item); + } + return add(item); + } + + public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabControl.Tab tab, String title, SVG leftGraphic) { + AdvancedListItem item = createNavigationDrawerItem(title, leftGraphic); + item.activeProperty().bind(tabHeader.getSelectionModel().selectedItemProperty().isEqualTo(tab)); + item.setOnAction(e -> tabHeader.select(tab)); + return add(item); + } + + public AdvancedListBox add(int index, Node child) { + if (child instanceof Pane || child instanceof AdvancedListItem) + container.getChildren().add(index, child); else { - StackPane pane = null; - for (Node node : container.getChildren()) + StackPane pane = new StackPane(); + pane.getStyleClass().add("advanced-list-box-item"); + pane.getChildren().setAll(child); + container.getChildren().add(index, pane); + } + return this; + } + + public AdvancedListBox remove(Node child) { + container.getChildren().remove(indexOf(child)); + return this; + } + + public int indexOf(Node child) { + if (child instanceof Pane) { + return container.getChildren().indexOf(child); + } else { + for (int i = 0; i < container.getChildren().size(); ++i) { + Node node = container.getChildren().get(i); if (node instanceof StackPane) { ObservableList list = ((StackPane) node).getChildren(); if (list.size() == 1 && list.get(0) == child) - pane = (StackPane) node; + return i; } - if (pane == null) - throw new Error(); - container.getChildren().remove(pane); + } + return -1; } - return this; } public AdvancedListBox startCategory(String category) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListItem.java index 20cec1d0bd..cdbedba2f3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListItem.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,29 +24,36 @@ import javafx.scene.control.Control; import javafx.scene.control.Skin; import javafx.scene.image.Image; -import javafx.scene.input.MouseEvent; +import javafx.scene.image.ImageView; +import javafx.scene.layout.StackPane; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.Pair; + +import static org.jackhuang.hmcl.util.Pair.pair; public class AdvancedListItem extends Control { - private final ObjectProperty image = new SimpleObjectProperty<>(this, "image"); + private final ObjectProperty leftGraphic = new SimpleObjectProperty<>(this, "leftGraphic"); private final ObjectProperty rightGraphic = new SimpleObjectProperty<>(this, "rightGraphic"); private final StringProperty title = new SimpleStringProperty(this, "title"); + private final BooleanProperty active = new SimpleBooleanProperty(this, "active"); private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle"); private final BooleanProperty actionButtonVisible = new SimpleBooleanProperty(this, "actionButtonVisible", true); public AdvancedListItem() { - addEventHandler(MouseEvent.MOUSE_CLICKED, e -> fireEvent(new ActionEvent())); + getStyleClass().add("advanced-list-item"); + FXUtils.onClicked(this, () -> fireEvent(new ActionEvent())); } - public Image getImage() { - return image.get(); + public Node getLeftGraphic() { + return leftGraphic.get(); } - public ObjectProperty imageProperty() { - return image; + public ObjectProperty leftGraphicProperty() { + return leftGraphic; } - public void setImage(Image image) { - this.image.set(image); + public void setLeftGraphic(Node leftGraphic) { + this.leftGraphic.set(leftGraphic); } public Node getRightGraphic() { @@ -73,6 +80,18 @@ public void setTitle(String title) { this.title.set(title); } + public boolean isActive() { + return active.get(); + } + + public BooleanProperty activeProperty() { + return active; + } + + public void setActive(boolean active) { + this.active.set(active); + } + public String getSubtitle() { return subtitle.get(); } @@ -120,4 +139,21 @@ protected void invalidated() { protected Skin createDefaultSkin() { return new AdvancedListItemSkin(this); } + + public static Pair createImageView(Image image) { + return createImageView(image, 32, 32); + } + + public static Pair createImageView(Image image, double width, double height) { + StackPane imageViewContainer = new StackPane(); + FXUtils.setLimitWidth(imageViewContainer, width); + FXUtils.setLimitHeight(imageViewContainer, height); + + ImageView imageView = new ImageView(); + FXUtils.limitSize(imageView, width, height); + imageView.setPreserveRatio(true); + imageView.setImage(image); + imageViewContainer.getChildren().setAll(imageView); + return pair(imageViewContainer, imageView); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListItemSkin.java index 3e524807b0..148e742917 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListItemSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListItemSkin.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,73 +17,52 @@ */ package org.jackhuang.hmcl.ui.construct; -import javafx.geometry.Insets; +import javafx.css.PseudoClass; import javafx.geometry.Pos; -import javafx.scene.control.Label; import javafx.scene.control.SkinBase; -import javafx.scene.image.ImageView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; -import javafx.scene.text.TextAlignment; import org.jackhuang.hmcl.ui.FXUtils; public class AdvancedListItemSkin extends SkinBase { + private final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); public AdvancedListItemSkin(AdvancedListItem skinnable) { super(skinnable); - StackPane stackPane = new StackPane(); - RipplerContainer container = new RipplerContainer(stackPane); + FXUtils.onChangeAndOperate(skinnable.activeProperty(), active -> { + skinnable.pseudoClassStateChanged(SELECTED, active); + }); BorderPane root = new BorderPane(); + root.getStyleClass().add("container"); root.setPickOnBounds(false); + RipplerContainer container = new RipplerContainer(root); + HBox left = new HBox(); - left.setAlignment(Pos.CENTER); + left.setAlignment(Pos.CENTER_LEFT); left.setMouseTransparent(true); - StackPane imageViewContainer = new StackPane(); - FXUtils.setLimitWidth(imageViewContainer, 32); - FXUtils.setLimitHeight(imageViewContainer, 32); - - ImageView imageView = new ImageView(); - FXUtils.limitSize(imageView, 32, 32); - imageView.setPreserveRatio(true); - imageView.imageProperty().bind(skinnable.imageProperty()); - imageViewContainer.getChildren().setAll(imageView); - - VBox vbox = new VBox(); - vbox.setAlignment(Pos.CENTER_LEFT); - vbox.setPadding(new Insets(0, 0, 0, 10)); - - Label title = new Label(); - title.textProperty().bind(skinnable.titleProperty()); - title.setMaxWidth(90); - title.setStyle("-fx-font-size: 15;"); - title.setTextAlignment(TextAlignment.JUSTIFY); - vbox.getChildren().add(title); - - Label subtitle = new Label(); - subtitle.textProperty().bind(skinnable.subtitleProperty()); - subtitle.setMaxWidth(90); - subtitle.setStyle("-fx-font-size: 10;"); - subtitle.setTextAlignment(TextAlignment.JUSTIFY); - vbox.getChildren().add(subtitle); - - FXUtils.onChangeAndOperate(skinnable.subtitleProperty(), subtitleString -> { - if (subtitleString == null) vbox.getChildren().setAll(title); - else vbox.getChildren().setAll(title, subtitle); - }); + TwoLineListItem item = new TwoLineListItem(); + root.setCenter(item); + item.setMouseTransparent(true); + item.titleProperty().bind(skinnable.titleProperty()); + item.subtitleProperty().bind(skinnable.subtitleProperty()); - left.getChildren().setAll(imageViewContainer, vbox); + FXUtils.onChangeAndOperate(skinnable.leftGraphicProperty(), + newGraphic -> { + if (newGraphic == null) { + left.getChildren().clear(); + } else { + left.getChildren().setAll(newGraphic); + } + }); root.setLeft(left); HBox right = new HBox(); right.setAlignment(Pos.CENTER); - right.setMouseTransparent(true); - right.getStyleClass().setAll("toggle-icon4"); + right.getStyleClass().add("toggle-icon4"); FXUtils.setLimitWidth(right, 40); FXUtils.onChangeAndOperate(skinnable.rightGraphicProperty(), newGraphic -> { @@ -93,16 +72,10 @@ public AdvancedListItemSkin(AdvancedListItem skinnable) { right.getChildren().setAll(newGraphic); } }); - root.setRight(right); FXUtils.onChangeAndOperate(skinnable.actionButtonVisibleProperty(), visible -> root.setRight(visible ? right : null)); - stackPane.setStyle("-fx-padding: 10 16 10 16;"); - stackPane.getStyleClass().setAll("transparent"); - stackPane.setPickOnBounds(false); - stackPane.getChildren().setAll(root); - getChildren().setAll(container); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ClassTitle.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ClassTitle.java index effcc5fcae..c4e9bb3f81 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ClassTitle.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ClassTitle.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java index f3680db723..03dd49b875 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,8 +17,6 @@ */ package org.jackhuang.hmcl.ui.construct; -import org.jackhuang.hmcl.util.javafx.MappedObservableList; - import javafx.beans.DefaultProperty; import javafx.beans.binding.Bindings; import javafx.beans.binding.ObjectBinding; @@ -29,10 +27,19 @@ import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Control; -import javafx.scene.control.SkinBase; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; +import org.jackhuang.hmcl.util.javafx.MappedObservableList; + +import java.util.List; +import java.util.function.Supplier; @DefaultProperty("content") public class ComponentList extends Control { @@ -41,11 +48,17 @@ public class ComponentList extends Control { private final IntegerProperty depth = new SimpleIntegerProperty(this, "depth", 0); private boolean hasSubtitle = false; public final ObservableList content = FXCollections.observableArrayList(); + private Supplier> lazyInitializer; public ComponentList() { getStyleClass().add("options-list"); } + public ComponentList(Supplier> lazyInitializer) { + this(); + this.lazyInitializer = lazyInitializer; + } + public String getTitle() { return title.get(); } @@ -94,23 +107,44 @@ public ObservableList getContent() { return content; } + void doLazyInit() { + if (lazyInitializer != null) { + this.getContent().setAll(lazyInitializer.get()); + setNeedsLayout(true); + lazyInitializer = null; + } + } + + @Override + public Orientation getContentBias() { + return Orientation.HORIZONTAL; + } + @Override protected javafx.scene.control.Skin createDefaultSkin() { return new Skin(this); } - protected static class Skin extends SkinBase { + private static final class Skin extends ControlSkinBase { private static final PseudoClass PSEUDO_CLASS_FIRST = PseudoClass.getPseudoClass("first"); + private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last"); private final ObservableList list; private final ObjectBinding firstItem; + private final ObjectBinding lastItem; - protected Skin(ComponentList control) { + Skin(ComponentList control) { super(control); list = MappedObservableList.create(control.getContent(), node -> { ComponentListCell cell = new ComponentListCell(node); cell.getStyleClass().add("options-list-item"); + if (node.getProperties().containsKey("ComponentList.vgrow")) { + VBox.setVgrow(cell, (Priority) node.getProperties().get("ComponentList.vgrow")); + } + if (node.getProperties().containsKey("ComponentList.noPadding")) { + cell.getStyleClass().add("no-padding"); + } return cell; }); @@ -124,9 +158,35 @@ protected Skin(ComponentList control) { if (!list.isEmpty()) list.get(0).pseudoClassStateChanged(PSEUDO_CLASS_FIRST, true); + lastItem = Bindings.valueAt(list, Bindings.subtract(Bindings.size(list), 1)); + lastItem.addListener((observable, oldValue, newValue) -> { + if (newValue != null) + newValue.pseudoClassStateChanged(PSEUDO_CLASS_LAST, true); + if (oldValue != null) + oldValue.pseudoClassStateChanged(PSEUDO_CLASS_LAST, false); + }); + if (!list.isEmpty()) + list.get(list.size() - 1).pseudoClassStateChanged(PSEUDO_CLASS_LAST, true); + VBox vbox = new VBox(); + vbox.setFillWidth(true); Bindings.bindContent(vbox.getChildren(), list); - getChildren().setAll(vbox); + node = vbox; } } + + public static Node createComponentListTitle(String title) { + HBox node = new HBox(); + node.setAlignment(Pos.CENTER_LEFT); + node.setPadding(new Insets(8, 0, 0, 0)); + { + Label advanced = new Label(title); + node.getChildren().setAll(advanced); + } + return node; + } + + public static void setVgrow(Node node, Priority priority) { + node.getProperties().put("ComponentList.vgrow", priority); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java index 79eda85c9b..5603f158d3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,26 +22,25 @@ import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; +import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Label; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; +import javafx.scene.layout.*; import javafx.scene.shape.Rectangle; import javafx.util.Duration; import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.animation.AnimationUtils; /** * @author huangyuhui */ -class ComponentListCell extends StackPane { +final class ComponentListCell extends StackPane { private final Node content; private Animation expandAnimation; private Rectangle clipRect; @@ -72,20 +71,24 @@ protected void layoutChildren() { } } + @SuppressWarnings("unchecked") private void updateLayout() { if (content instanceof ComponentList) { ComponentList list = (ComponentList) content; content.getStyleClass().remove("options-list"); content.getStyleClass().add("options-sublist"); - BorderPane groupNode = new BorderPane(); + getStyleClass().add("no-padding"); - Node expandIcon = SVG.expand(Theme.blackFillBinding(), 10, 10); + VBox groupNode = new VBox(); + + Node expandIcon = SVG.KEYBOARD_ARROW_DOWN.createIcon(Theme.blackFill(), 20); JFXButton expandButton = new JFXButton(); expandButton.setGraphic(expandIcon); expandButton.getStyleClass().add("options-list-item-expand-button"); VBox labelVBox = new VBox(); + labelVBox.setMouseTransparent(true); labelVBox.setAlignment(Pos.CENTER_LEFT); boolean overrideHeaderLeft = false; @@ -110,59 +113,82 @@ private void updateLayout() { } } - groupNode.setLeft(labelVBox); - - HBox right = new HBox(); - right.setSpacing(16); - right.setAlignment(Pos.CENTER_RIGHT); + HBox header = new HBox(); + header.setSpacing(16); + header.getChildren().add(labelVBox); + header.setPadding(new Insets(10, 16, 10, 16)); + header.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(labelVBox, Priority.ALWAYS); if (list instanceof ComponentSublist) { Node rightNode = ((ComponentSublist) list).getHeaderRight(); if (rightNode != null) - right.getChildren().add(rightNode); + header.getChildren().add(rightNode); } - right.getChildren().add(expandButton); - groupNode.setRight(right); + header.getChildren().add(expandButton); + + RipplerContainer headerRippler = new RipplerContainer(header); + groupNode.getChildren().add(headerRippler); VBox container = new VBox(); - container.setPadding(new Insets(8, 0, 0, 0)); + container.setPadding(new Insets(8, 16, 10, 16)); FXUtils.setLimitHeight(container, 0); - FXUtils.setOverflowHidden(container, true); + FXUtils.setOverflowHidden(container); container.getChildren().setAll(content); - groupNode.setBottom(container); + groupNode.getChildren().add(container); - expandButton.setOnMouseClicked(e -> { + Runnable onExpand = () -> { if (expandAnimation != null && expandAnimation.getStatus() == Animation.Status.RUNNING) { expandAnimation.stop(); } - setExpanded(!isExpanded()); + boolean expanded = !isExpanded(); + setExpanded(expanded); + if (expanded) { + list.doLazyInit(); + list.layout(); + } - double newAnimatedHeight = content.prefHeight(-1) * (isExpanded() ? 1 : -1); - double newHeight = isExpanded() ? getHeight() + newAnimatedHeight : prefHeight(-1); - double contentHeight = isExpanded() ? newAnimatedHeight : 0; + Platform.runLater(() -> { + double newAnimatedHeight = (list.prefHeight(list.getWidth()) + 8 + 10) * (expanded ? 1 : -1); + double newHeight = expanded ? getHeight() + newAnimatedHeight : prefHeight(list.getWidth()); + double contentHeight = expanded ? newAnimatedHeight : 0; - if (isExpanded()) { - updateClip(newHeight); - } + if (expanded) { + updateClip(newHeight); + } - expandAnimation = new Timeline(new KeyFrame(new Duration(320.0), - new KeyValue(container.minHeightProperty(), contentHeight, FXUtils.SINE), - new KeyValue(container.maxHeightProperty(), contentHeight, FXUtils.SINE) - )); + if (AnimationUtils.isAnimationEnabled()) { + expandAnimation = new Timeline(new KeyFrame(new Duration(320.0), + new KeyValue(container.minHeightProperty(), contentHeight, FXUtils.SINE), + new KeyValue(container.maxHeightProperty(), contentHeight, FXUtils.SINE) + )); - if (!isExpanded()) { - expandAnimation.setOnFinished(e2 -> updateClip(newHeight)); - } + if (!expanded) { + expandAnimation.setOnFinished(e2 -> updateClip(newHeight)); + } + + expandAnimation.play(); + } else { + container.setMinHeight(contentHeight); + container.setMaxHeight(contentHeight); - expandAnimation.play(); - }); + if (!expanded) { + updateClip(newHeight); + } + } + }); + }; - expandedProperty().addListener((a, b, newValue) -> - expandIcon.setRotate(newValue ? 180 : 0)); + FXUtils.onClicked(headerRippler, onExpand); + expandButton.setOnAction(e -> onExpand.run()); + + expandedProperty().addListener((a, b, newValue) -> expandIcon.setRotate(newValue ? 180 : 0)); getChildren().setAll(groupNode); - } else + } else { + getStyleClass().remove("no-padding"); getChildren().setAll(content); + } } public boolean isExpanded() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java index 6813112d33..d1bf8f54a0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,7 +23,7 @@ import javafx.scene.Node; @DefaultProperty("content") -public final class ComponentSublist extends ComponentList { +public class ComponentSublist extends ComponentList { private final ObjectProperty headerLeft = new SimpleObjectProperty<>(this, "headerLeft"); private final ObjectProperty headerRight = new SimpleObjectProperty<>(this, "headerRight"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ControlSkinBase.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ControlSkinBase.java new file mode 100644 index 0000000000..60052350d8 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ControlSkinBase.java @@ -0,0 +1,55 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2022 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.construct; + +import javafx.scene.Node; +import javafx.scene.control.Control; +import javafx.scene.control.Skin; + +import java.util.Objects; + +public abstract class ControlSkinBase implements Skin { + private final C control; + + protected Node node; + + /** + * Constructor for all SkinBase instances. + * + * @param control The control for which this Skin should attach to. + */ + protected ControlSkinBase(C control) { + this.control = control; + } + + @Override + public C getSkinnable() { + return control; + } + + @Override + public Node getNode() { + Objects.requireNonNull(node); + return node; + } + + @Override + public void dispose() { + + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogAware.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogAware.java index 06ec6b54ab..4ab176c29b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogAware.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogAware.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -28,4 +28,7 @@ public interface DialogAware { default void onDialogShown() { } + default void onDialogClosed() { + } + } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogCloseEvent.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogCloseEvent.java index 0f03572d25..4c82432124 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogCloseEvent.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogCloseEvent.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogPane.java new file mode 100644 index 0000000000..0a2e9228d8 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogPane.java @@ -0,0 +1,119 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.construct; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXDialogLayout; +import com.jfoenix.controls.JFXProgressBar; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.layout.StackPane; + +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public class DialogPane extends JFXDialogLayout { + private final StringProperty title = new SimpleStringProperty(); + private final BooleanProperty valid = new SimpleBooleanProperty(true); + protected final SpinnerPane acceptPane = new SpinnerPane(); + protected final JFXButton cancelButton = new JFXButton(); + protected final Label warningLabel = new Label(); + private final JFXProgressBar progressBar = new JFXProgressBar(); + + public DialogPane() { + Label titleLabel = new Label(); + titleLabel.textProperty().bind(title); + setHeading(titleLabel); + getChildren().add(progressBar); + + progressBar.setVisible(false); + StackPane.setMargin(progressBar, new Insets(-24.0D, -24.0D, -16.0D, -24.0D)); + StackPane.setAlignment(progressBar, Pos.TOP_CENTER); + progressBar.setMaxWidth(Double.MAX_VALUE); + + JFXButton acceptButton = new JFXButton(i18n("button.ok")); + acceptButton.setOnAction(e -> onAccept()); + acceptButton.disableProperty().bind(valid.not()); + acceptButton.getStyleClass().add("dialog-accept"); + acceptPane.getStyleClass().add("small-spinner-pane"); + acceptPane.setContent(acceptButton); + + cancelButton.setText(i18n("button.cancel")); + cancelButton.setOnAction(e -> onCancel()); + cancelButton.getStyleClass().add("dialog-cancel"); + onEscPressed(this, cancelButton::fire); + + setActions(warningLabel, acceptPane, cancelButton); + } + + protected JFXProgressBar getProgressBar() { + return progressBar; + } + + public String getTitle() { + return title.get(); + } + + public StringProperty titleProperty() { + return title; + } + + public void setTitle(String title) { + this.title.set(title); + } + + public boolean isValid() { + return valid.get(); + } + + public BooleanProperty validProperty() { + return valid; + } + + public void setValid(boolean valid) { + this.valid.set(valid); + } + + protected void onCancel() { + fireEvent(new DialogCloseEvent()); + } + + protected void onAccept() { + fireEvent(new DialogCloseEvent()); + } + + protected void setLoading() { + acceptPane.showSpinner(); + warningLabel.setText(""); + } + + protected void onSuccess() { + acceptPane.hideSpinner(); + fireEvent(new DialogCloseEvent()); + } + + protected void onFailure(String msg) { + acceptPane.hideSpinner(); + warningLabel.setText(msg); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DoubleValidator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DoubleValidator.java new file mode 100644 index 0000000000..2bf28f8952 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DoubleValidator.java @@ -0,0 +1,57 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.construct; + +import com.jfoenix.validation.base.ValidatorBase; +import javafx.beans.NamedArg; +import javafx.scene.control.TextInputControl; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; + +public class DoubleValidator extends ValidatorBase { + private final boolean nullable; + + public DoubleValidator() { + this(false); + } + + public DoubleValidator(@NamedArg("nullable") boolean nullable) { + this.nullable = nullable; + } + + public DoubleValidator(@NamedArg("message") String message, @NamedArg("nullable") boolean nullable) { + super(message); + this.nullable = nullable; + } + + @Override + protected void eval() { + if (srcControl.get() instanceof TextInputControl) { + evalTextInputField(); + } + } + + private void evalTextInputField() { + TextInputControl textField = ((TextInputControl) srcControl.get()); + + if (StringUtils.isBlank(textField.getText())) + hasErrors.set(!nullable); + else + hasErrors.set(Lang.toDoubleOrNull(textField.getText()) == null); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileItem.java index 7cbd5ac614..ccf722f3b0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileItem.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -27,17 +27,19 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.VBox; import javafx.stage.DirectoryChooser; +import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; -import java.io.File; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; -import java.nio.file.Paths; import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class FileItem extends BorderPane { private final Label lblPath = new Label(); @@ -58,9 +60,9 @@ public FileItem() { setLeft(left); JFXButton right = new JFXButton(); - right.setGraphic(SVG.pencil(Theme.blackFillBinding(), 15, 15)); + right.setGraphic(SVG.EDIT.createIcon(Theme.blackFill(), 16)); right.getStyleClass().add("toggle-icon4"); - right.setOnMouseClicked(e -> onExplore()); + right.setOnAction(e -> onExplore()); FXUtils.installFastTooltip(right, i18n("button.edit")); setRight(right); @@ -75,10 +77,16 @@ public FileItem() { * Converts the given path to absolute/relative(if possible) path according to {@link #convertToRelativePathProperty()}. */ private String processPath(String path) { - Path given = Paths.get(path).toAbsolutePath(); + Path given; + try { + given = Path.of(path).toAbsolutePath().normalize(); + } catch (IllegalArgumentException e) { + return path; + } + if (isConvertToRelativePath()) { try { - return Paths.get(".").normalize().toAbsolutePath().relativize(given).normalize().toString(); + return Metadata.CURRENT_DIRECTORY.relativize(given).normalize().toString(); } catch (IllegalArgumentException e) { // the given path can't be relativized against current path } @@ -89,17 +97,22 @@ private String processPath(String path) { public void onExplore() { DirectoryChooser chooser = new DirectoryChooser(); if (path.get() != null) { - File file = new File(path.get()); - if (file.exists()) { - if (file.isFile()) - file = file.getAbsoluteFile().getParentFile(); - else if (file.isDirectory()) - file = file.getAbsoluteFile(); - chooser.setInitialDirectory(file); + Path file; + try { + file = Path.of(path.get()); + if (Files.exists(file)) { + if (Files.isRegularFile(file)) + file = file.toAbsolutePath().normalize().getParent(); + else if (Files.isDirectory(file)) + file = file.toAbsolutePath().normalize(); + chooser.setInitialDirectory(file.toFile()); + } + } catch (InvalidPathException e) { + LOG.warning("Failed to resolve path: " + path.get()); } } chooser.titleProperty().bind(titleProperty()); - File selectedDir = chooser.showDialog(Controllers.getStage()); + var selectedDir = chooser.showDialog(Controllers.getStage()); if (selectedDir != null) { path.set(processPath(selectedDir.toString())); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java new file mode 100644 index 0000000000..96cffb9a0f --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java @@ -0,0 +1,113 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.construct; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXTextField; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Pos; +import javafx.scene.layout.HBox; +import javafx.stage.DirectoryChooser; +import javafx.stage.FileChooser; +import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.util.io.FileUtils; + +import java.nio.file.Path; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public class FileSelector extends HBox { + private final StringProperty value = new SimpleStringProperty(); + private String chooserTitle = i18n("selector.choose_file"); + private boolean directory = false; + private final ObservableList extensionFilters = FXCollections.observableArrayList(); + + public String getValue() { + return value.get(); + } + + public StringProperty valueProperty() { + return value; + } + + public void setValue(String value) { + this.value.set(value); + } + + public String getChooserTitle() { + return chooserTitle; + } + + public FileSelector setChooserTitle(String chooserTitle) { + this.chooserTitle = chooserTitle; + return this; + } + + public boolean isDirectory() { + return directory; + } + + public FileSelector setDirectory(boolean directory) { + this.directory = directory; + return this; + } + + public ObservableList getExtensionFilters() { + return extensionFilters; + } + + public FileSelector() { + JFXTextField customField = new JFXTextField(); + FXUtils.bindString(customField, valueProperty()); + + JFXButton selectButton = new JFXButton(); + selectButton.setGraphic(SVG.FOLDER_OPEN.createIcon(Theme.blackFill(), 15)); + selectButton.setOnAction(e -> { + if (directory) { + DirectoryChooser chooser = new DirectoryChooser(); + chooser.setTitle(chooserTitle); + Path dir = FileUtils.toPath(chooser.showDialog(Controllers.getStage())); + if (dir != null) { + String path = FileUtils.getAbsolutePath(dir); + customField.setText(path); + value.setValue(path); + } + } else { + FileChooser chooser = new FileChooser(); + chooser.getExtensionFilters().addAll(getExtensionFilters()); + chooser.setTitle(chooserTitle); + Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage())); + if (file != null) { + String path = FileUtils.getAbsolutePath(file); + customField.setText(path); + value.setValue(path); + } + } + }); + + setAlignment(Pos.CENTER_LEFT); + setSpacing(3); + getChildren().addAll(customField, selectButton); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatListCell.java new file mode 100644 index 0000000000..dc4faf0626 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatListCell.java @@ -0,0 +1,69 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.construct; + +import com.jfoenix.controls.JFXListView; +import com.jfoenix.effects.JFXDepthManager; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.scene.Cursor; +import javafx.scene.control.ListCell; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import org.jackhuang.hmcl.ui.FXUtils; + +public abstract class FloatListCell extends ListCell { + private final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); + + protected final StackPane pane = new StackPane(); + + public FloatListCell(JFXListView listView) { + setText(null); + setGraphic(null); + + pane.getStyleClass().add("card"); + pane.setCursor(Cursor.HAND); + setPadding(new Insets(9, 9, 0, 9)); + JFXDepthManager.setDepth(pane, 1); + + FXUtils.onChangeAndOperate(selectedProperty(), selected -> { + pane.pseudoClassStateChanged(SELECTED, selected); + }); + + Region clippedContainer = (Region) listView.lookup(".clipped-container"); + setPrefWidth(0); + if (clippedContainer != null) { + maxWidthProperty().bind(clippedContainer.widthProperty()); + prefWidthProperty().bind(clippedContainer.widthProperty()); + minWidthProperty().bind(clippedContainer.widthProperty()); + } + } + + @Override + protected void updateItem(T item, boolean empty) { + super.updateItem(item, empty); + updateControl(item, empty); + if (empty) { + setGraphic(null); + } else { + setGraphic(pane); + } + } + + protected abstract void updateControl(T dataItem, boolean empty); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatScrollBarSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatScrollBarSkin.java new file mode 100644 index 0000000000..c14b73c032 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatScrollBarSkin.java @@ -0,0 +1,192 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.construct; + +import javafx.beans.binding.Bindings; +import javafx.beans.binding.NumberBinding; +import javafx.geometry.Orientation; +import javafx.geometry.Point2D; +import javafx.scene.Node; +import javafx.scene.control.ScrollBar; +import javafx.scene.control.Skin; +import javafx.scene.layout.Region; +import javafx.scene.shape.Rectangle; +import org.jackhuang.hmcl.util.Lang; + +public class FloatScrollBarSkin implements Skin { + private ScrollBar scrollBar; + private Region group; + private Rectangle track = new Rectangle(); + private Rectangle thumb = new Rectangle(); + + public FloatScrollBarSkin(final ScrollBar scrollBar) { + this.scrollBar = scrollBar; + scrollBar.setPrefHeight(1e-18); + scrollBar.setPrefWidth(1e-18); + + this.group = new Region() { + Point2D dragStart; + double preDragThumbPos; + + NumberBinding range = Bindings.subtract(scrollBar.maxProperty(), scrollBar.minProperty()); + NumberBinding position = Bindings.divide(Bindings.subtract(scrollBar.valueProperty(), scrollBar.minProperty()), range); + + { + // Children are added unmanaged because for some reason the height of the bar keeps changing + // if they're managed in certain situations... not sure about the cause. + getChildren().addAll(track, thumb); + + track.setManaged(false); + track.getStyleClass().add("track"); + + thumb.setManaged(false); + thumb.getStyleClass().add("thumb"); + + scrollBar.orientationProperty().addListener(obs -> setup()); + + setup(); + + + thumb.setOnMousePressed(me -> { + if (me.isSynthesized()) { + // touch-screen events handled by Scroll handler + me.consume(); + return; + } + /* + ** if max isn't greater than min then there is nothing to do here + */ + if (getSkinnable().getMax() > getSkinnable().getMin()) { + dragStart = thumb.localToParent(me.getX(), me.getY()); + double clampedValue = Lang.clamp(getSkinnable().getMin(), getSkinnable().getValue(), getSkinnable().getMax()); + preDragThumbPos = (clampedValue - getSkinnable().getMin()) / (getSkinnable().getMax() - getSkinnable().getMin()); + me.consume(); + } + }); + + + thumb.setOnMouseDragged(me -> { + if (me.isSynthesized()) { + // touch-screen events handled by Scroll handler + me.consume(); + return; + } + /* + ** if max isn't greater than min then there is nothing to do here + */ + if (getSkinnable().getMax() > getSkinnable().getMin()) { + /* + ** if the tracklength isn't greater then do nothing.... + */ + if (trackLength() > thumbLength()) { + Point2D cur = thumb.localToParent(me.getX(), me.getY()); + if (dragStart == null) { + // we're getting dragged without getting a mouse press + dragStart = thumb.localToParent(me.getX(), me.getY()); + } + double dragPos = getSkinnable().getOrientation() == Orientation.VERTICAL ? cur.getY() - dragStart.getY() : cur.getX() - dragStart.getX(); + double position = preDragThumbPos + dragPos / (trackLength() - thumbLength()); + if (!getSkinnable().isFocused() && getSkinnable().isFocusTraversable()) + getSkinnable().requestFocus(); + double newValue = (position * (getSkinnable().getMax() - getSkinnable().getMin())) + getSkinnable().getMin(); + if (!Double.isNaN(newValue)) { + getSkinnable().setValue(Lang.clamp(getSkinnable().getMin(), newValue, getSkinnable().getMax())); + } + } + + me.consume(); + } + }); + } + + private double trackLength() { + return getSkinnable().getOrientation() == Orientation.VERTICAL ? track.getHeight() : track.getWidth(); + } + + private double thumbLength() { + return getSkinnable().getOrientation() == Orientation.VERTICAL ? thumb.getHeight() : thumb.getWidth(); + } + + private void setup() { + track.widthProperty().unbind(); + track.heightProperty().unbind(); + + if (scrollBar.getOrientation() == Orientation.HORIZONTAL) { + track.relocate(0, -5); + track.widthProperty().bind(scrollBar.widthProperty()); + track.setHeight(5); + } else { + track.relocate(-5, 0); + track.setWidth(5); + track.heightProperty().bind(scrollBar.heightProperty()); + } + + thumb.xProperty().unbind(); + thumb.yProperty().unbind(); + thumb.widthProperty().unbind(); + thumb.heightProperty().unbind(); + + if (scrollBar.getOrientation() == Orientation.HORIZONTAL) { + thumb.relocate(0, -5); + thumb.widthProperty().bind(Bindings.max(20, scrollBar.visibleAmountProperty().divide(range).multiply(scrollBar.widthProperty()))); + thumb.setHeight(5); + thumb.xProperty().bind(Bindings.subtract(scrollBar.widthProperty(), thumb.widthProperty()).multiply(position)); + } else { + thumb.relocate(-5, 0); + thumb.setWidth(5); + thumb.heightProperty().bind(Bindings.max(20, scrollBar.visibleAmountProperty().divide(range).multiply(scrollBar.heightProperty()))); + thumb.yProperty().bind(Bindings.subtract(scrollBar.heightProperty(), thumb.heightProperty()).multiply(position)); + } + } + + @Override + protected double computeMaxWidth(double height) { + if (scrollBar.getOrientation() == Orientation.HORIZONTAL) { + return Double.MAX_VALUE; + } + + return 5; + } + + @Override + protected double computeMaxHeight(double width) { + if (scrollBar.getOrientation() == Orientation.VERTICAL) { + return Double.MAX_VALUE; + } + + return 5; + } + }; + } + + @Override + public void dispose() { + scrollBar = null; + group = null; + } + + @Override + public Node getNode() { + return group; + } + + @Override + public ScrollBar getSkinnable() { + return scrollBar; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FontComboBox.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FontComboBox.java index abb1d10f36..64ffda3317 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FontComboBox.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FontComboBox.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,20 +21,22 @@ import static javafx.collections.FXCollections.observableList; import static javafx.collections.FXCollections.singletonObservableList; -import org.jackhuang.hmcl.util.javafx.MultiStepBinding; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.javafx.BindingMapping; import com.jfoenix.controls.JFXComboBox; import com.jfoenix.controls.JFXListCell; -import javafx.beans.NamedArg; import javafx.beans.binding.Bindings; import javafx.scene.text.Font; -public class FontComboBox extends JFXComboBox { +public final class FontComboBox extends JFXComboBox { private boolean loaded = false; - public FontComboBox(@NamedArg(value = "fontSize", defaultValue = "12.0") double fontSize) { + public FontComboBox() { + setMinWidth(260); + styleProperty().bind(Bindings.concat("-fx-font-family: \"", valueProperty(), "\"")); setCellFactory(listView -> new JFXListCell() { @@ -49,10 +51,10 @@ public void updateItem(String item, boolean empty) { } }); - itemsProperty().bind(MultiStepBinding.of(valueProperty()) + itemsProperty().bind(BindingMapping.of(valueProperty()) .map(value -> value == null ? emptyObservableList() : singletonObservableList(value))); - setOnMouseClicked(e -> { + FXUtils.onClicked(this, () -> { if (loaded) return; itemsProperty().unbind(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/HintPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/HintPane.java new file mode 100644 index 0000000000..aa95e093f3 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/HintPane.java @@ -0,0 +1,97 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.construct; + +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; +import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; + +import java.util.Locale; + +public class HintPane extends VBox { + private final Text label = new Text(); + private final StringProperty text = new SimpleStringProperty(this, "text"); + private final TextFlow flow = new TextFlow(); + + public HintPane() { + this(MessageDialogPane.MessageType.INFO); + } + + public HintPane(MessageDialogPane.MessageType type) { + setFillWidth(true); + getStyleClass().addAll("hint", type.name().toLowerCase(Locale.ROOT)); + + SVG svg; + switch (type) { + case INFO: + svg = SVG.INFO; + break; + case ERROR: + svg = SVG.ERROR; + break; + case SUCCESS: + svg = SVG.CHECK_CIRCLE; + break; + case WARNING: + svg = SVG.WARNING; + break; + case QUESTION: + svg = SVG.HELP; + break; + default: + throw new IllegalArgumentException("Unrecognized message box message type " + type); + } + + HBox hbox = new HBox(svg.createIcon(Theme.blackFill(), 16), new Text(type.getDisplayName())); + hbox.setAlignment(Pos.CENTER_LEFT); + flow.getChildren().setAll(label); + getChildren().setAll(hbox, flow); + label.textProperty().bind(text); + VBox.setMargin(flow, new Insets(2, 2, 0, 2)); + } + + public String getText() { + return text.get(); + } + + public StringProperty textProperty() { + return text; + } + + public void setText(String text) { + this.text.set(text); + } + + public void setSegment(String segment) { + flow.getChildren().setAll(FXUtils.parseSegment(segment, Controllers::onHyperlinkAction)); + } + + public void setChildren(Node... children) { + flow.getChildren().setAll(children); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java index fe2d9dd551..e45aa02af1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,7 +17,6 @@ */ package org.jackhuang.hmcl.ui.construct; -import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.layout.HBox; @@ -45,7 +44,7 @@ private static HBox createHBox(Node icon) { hBox.getChildren().add(icon); } - hBox.getStyleClass().setAll("iconed-item-container"); + hBox.getStyleClass().add("iconed-item-container"); Label textLabel = new Label(); textLabel.setId("label"); textLabel.setMouseTransparent(true); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedMenuItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedMenuItem.java index 49d9152290..076e095e0b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedMenuItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedMenuItem.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2019 huangyuhui and contributors + * Copyright (C) 2020 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,16 +17,26 @@ */ package org.jackhuang.hmcl.ui.construct; -import javafx.scene.Node; +import com.jfoenix.controls.JFXPopup; +import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; public class IconedMenuItem extends IconedItem { - public IconedMenuItem(Node node, String text, Runnable action) { - super(node, text); + public IconedMenuItem(SVG icon, String text, Runnable action, JFXPopup popup) { + super(icon != null ? FXUtils.limitingSize(icon.createIcon(Theme.blackFill(), 14), 14, 14) : null, text); getStyleClass().setAll("iconed-menu-item"); - setOnMouseClicked(e -> action.run()); + + if (popup == null) { + FXUtils.onClicked(this, action); + } else { + FXUtils.onClicked(this, () -> { + action.run(); + popup.hide(); + }); + } } public IconedMenuItem addTooltip(String tooltip) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedTwoLineListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedTwoLineListItem.java new file mode 100644 index 0000000000..3687dfea7a --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedTwoLineListItem.java @@ -0,0 +1,119 @@ +package org.jackhuang.hmcl.ui.construct; + +import com.jfoenix.controls.JFXButton; +import javafx.beans.InvalidationListener; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.util.StringUtils; + +public class IconedTwoLineListItem extends HBox { + private final StringProperty title = new SimpleStringProperty(this, "title"); + private final ObservableList