diff --git a/assets/data/tool-ui/age_calculator.dat b/assets/data/tool-ui/age_calculator.dat index dca91d3..a553351 100644 Binary files a/assets/data/tool-ui/age_calculator.dat and b/assets/data/tool-ui/age_calculator.dat differ diff --git a/assets/data/tool-ui/ai_latest_news.dat b/assets/data/tool-ui/ai_latest_news.dat index 00b7976..a427fe1 100644 Binary files a/assets/data/tool-ui/ai_latest_news.dat and b/assets/data/tool-ui/ai_latest_news.dat differ diff --git a/assets/data/tool-ui/ai_translation.dat b/assets/data/tool-ui/ai_translation.dat index 8eded05..e49ea2a 100644 Binary files a/assets/data/tool-ui/ai_translation.dat and b/assets/data/tool-ui/ai_translation.dat differ diff --git a/assets/data/tool-ui/archive_tool.dat b/assets/data/tool-ui/archive_tool.dat index 46fad0e..7e3d12b 100644 Binary files a/assets/data/tool-ui/archive_tool.dat and b/assets/data/tool-ui/archive_tool.dat differ diff --git a/assets/data/tool-ui/area_converter.dat b/assets/data/tool-ui/area_converter.dat index 7966b8a..979e4ba 100644 Binary files a/assets/data/tool-ui/area_converter.dat and b/assets/data/tool-ui/area_converter.dat differ diff --git a/assets/data/tool-ui/ascii_art.dat b/assets/data/tool-ui/ascii_art.dat index cbd33d2..618afea 100644 Binary files a/assets/data/tool-ui/ascii_art.dat and b/assets/data/tool-ui/ascii_art.dat differ diff --git a/assets/data/tool-ui/average_calculator.dat b/assets/data/tool-ui/average_calculator.dat index 6e14346..a85ef2b 100644 Binary files a/assets/data/tool-ui/average_calculator.dat and b/assets/data/tool-ui/average_calculator.dat differ diff --git a/assets/data/tool-ui/baidu_hot.dat b/assets/data/tool-ui/baidu_hot.dat index 396070a..4e26db5 100644 Binary files a/assets/data/tool-ui/baidu_hot.dat and b/assets/data/tool-ui/baidu_hot.dat differ diff --git a/assets/data/tool-ui/base64_codec.dat b/assets/data/tool-ui/base64_codec.dat index f820b52..62c7142 100644 Binary files a/assets/data/tool-ui/base64_codec.dat and b/assets/data/tool-ui/base64_codec.dat differ diff --git a/assets/data/tool-ui/batch_rename_preview.dat b/assets/data/tool-ui/batch_rename_preview.dat index 9f2e82d..c02a0e5 100644 Binary files a/assets/data/tool-ui/batch_rename_preview.dat and b/assets/data/tool-ui/batch_rename_preview.dat differ diff --git a/assets/data/tool-ui/bili_hot.dat b/assets/data/tool-ui/bili_hot.dat index 179f9cd..20b778c 100644 Binary files a/assets/data/tool-ui/bili_hot.dat and b/assets/data/tool-ui/bili_hot.dat differ diff --git a/assets/data/tool-ui/blank_line_cleaner.dat b/assets/data/tool-ui/blank_line_cleaner.dat index 6e210c6..d1ed7b3 100644 Binary files a/assets/data/tool-ui/blank_line_cleaner.dat and b/assets/data/tool-ui/blank_line_cleaner.dat differ diff --git a/assets/data/tool-ui/bmi_calculator.dat b/assets/data/tool-ui/bmi_calculator.dat index aa428df..5a0cdbe 100644 Binary files a/assets/data/tool-ui/bmi_calculator.dat and b/assets/data/tool-ui/bmi_calculator.dat differ diff --git a/assets/data/tool-ui/calculator_tool.dat b/assets/data/tool-ui/calculator_tool.dat index 5508a87..8eb9ee5 100644 Binary files a/assets/data/tool-ui/calculator_tool.dat and b/assets/data/tool-ui/calculator_tool.dat differ diff --git a/assets/data/tool-ui/car_info.dat b/assets/data/tool-ui/car_info.dat index 536f5e5..a0d1011 100644 Binary files a/assets/data/tool-ui/car_info.dat and b/assets/data/tool-ui/car_info.dat differ diff --git a/assets/data/tool-ui/case_converter.dat b/assets/data/tool-ui/case_converter.dat index 0b1664c..4025329 100644 Binary files a/assets/data/tool-ui/case_converter.dat and b/assets/data/tool-ui/case_converter.dat differ diff --git a/assets/data/tool-ui/cctv_news.dat b/assets/data/tool-ui/cctv_news.dat index 504e4b4..5de3da7 100644 Binary files a/assets/data/tool-ui/cctv_news.dat and b/assets/data/tool-ui/cctv_news.dat differ diff --git a/assets/data/tool-ui/charset_lookup.dat b/assets/data/tool-ui/charset_lookup.dat index 4c6319b..37559ff 100644 Binary files a/assets/data/tool-ui/charset_lookup.dat and b/assets/data/tool-ui/charset_lookup.dat differ diff --git a/assets/data/tool-ui/chinese_converter.dat b/assets/data/tool-ui/chinese_converter.dat index 74a5746..08bb307 100644 Binary files a/assets/data/tool-ui/chinese_converter.dat and b/assets/data/tool-ui/chinese_converter.dat differ diff --git a/assets/data/tool-ui/cidr_subnet_calculator.dat b/assets/data/tool-ui/cidr_subnet_calculator.dat index 761895d..8379dd7 100644 Binary files a/assets/data/tool-ui/cidr_subnet_calculator.dat and b/assets/data/tool-ui/cidr_subnet_calculator.dat differ diff --git a/assets/data/tool-ui/city_route_query.dat b/assets/data/tool-ui/city_route_query.dat index bc1ef1e..45b3b11 100644 Binary files a/assets/data/tool-ui/city_route_query.dat and b/assets/data/tool-ui/city_route_query.dat differ diff --git a/assets/data/tool-ui/color_contrast_checker.dat b/assets/data/tool-ui/color_contrast_checker.dat index d395269..a807fb4 100644 Binary files a/assets/data/tool-ui/color_contrast_checker.dat and b/assets/data/tool-ui/color_contrast_checker.dat differ diff --git a/assets/data/tool-ui/color_picker.dat b/assets/data/tool-ui/color_picker.dat index c1b8f57..63711e6 100644 Binary files a/assets/data/tool-ui/color_picker.dat and b/assets/data/tool-ui/color_picker.dat differ diff --git a/assets/data/tool-ui/column_extractor.dat b/assets/data/tool-ui/column_extractor.dat index a8a8165..3e87779 100644 Binary files a/assets/data/tool-ui/column_extractor.dat and b/assets/data/tool-ui/column_extractor.dat differ diff --git a/assets/data/tool-ui/combination_calculator.dat b/assets/data/tool-ui/combination_calculator.dat index 787d025..d66dc1e 100644 Binary files a/assets/data/tool-ui/combination_calculator.dat and b/assets/data/tool-ui/combination_calculator.dat differ diff --git a/assets/data/tool-ui/compound_interest_calculator.dat b/assets/data/tool-ui/compound_interest_calculator.dat index 41aa443..1a871ef 100644 Binary files a/assets/data/tool-ui/compound_interest_calculator.dat and b/assets/data/tool-ui/compound_interest_calculator.dat differ diff --git a/assets/data/tool-ui/compression_codec.dat b/assets/data/tool-ui/compression_codec.dat index 7eee023..1387e46 100644 Binary files a/assets/data/tool-ui/compression_codec.dat and b/assets/data/tool-ui/compression_codec.dat differ diff --git a/assets/data/tool-ui/cron_helper.dat b/assets/data/tool-ui/cron_helper.dat index 30b4948..c152b08 100644 Binary files a/assets/data/tool-ui/cron_helper.dat and b/assets/data/tool-ui/cron_helper.dat differ diff --git a/assets/data/tool-ui/csv_json_converter.dat b/assets/data/tool-ui/csv_json_converter.dat index ac86fd2..dcd4df6 100644 Binary files a/assets/data/tool-ui/csv_json_converter.dat and b/assets/data/tool-ui/csv_json_converter.dat differ diff --git a/assets/data/tool-ui/csv_tsv_converter.dat b/assets/data/tool-ui/csv_tsv_converter.dat index ecf377b..38e223b 100644 Binary files a/assets/data/tool-ui/csv_tsv_converter.dat and b/assets/data/tool-ui/csv_tsv_converter.dat differ diff --git a/assets/data/tool-ui/data_size_converter.dat b/assets/data/tool-ui/data_size_converter.dat index 1860182..9d16f63 100644 Binary files a/assets/data/tool-ui/data_size_converter.dat and b/assets/data/tool-ui/data_size_converter.dat differ diff --git a/assets/data/tool-ui/date_difference_calculator.dat b/assets/data/tool-ui/date_difference_calculator.dat index 121e8bc..5a14e95 100644 Binary files a/assets/data/tool-ui/date_difference_calculator.dat and b/assets/data/tool-ui/date_difference_calculator.dat differ diff --git a/assets/data/tool-ui/delimiter_converter.dat b/assets/data/tool-ui/delimiter_converter.dat index abe6a1b..4338890 100644 Binary files a/assets/data/tool-ui/delimiter_converter.dat and b/assets/data/tool-ui/delimiter_converter.dat differ diff --git a/assets/data/tool-ui/dev_environment_config.dat b/assets/data/tool-ui/dev_environment_config.dat index 656f479..c04156c 100644 Binary files a/assets/data/tool-ui/dev_environment_config.dat and b/assets/data/tool-ui/dev_environment_config.dat differ diff --git a/assets/data/tool-ui/discount_calculator.dat b/assets/data/tool-ui/discount_calculator.dat index 9596ba0..d0a2b2a 100644 Binary files a/assets/data/tool-ui/discount_calculator.dat and b/assets/data/tool-ui/discount_calculator.dat differ diff --git a/assets/data/tool-ui/dns_query.dat b/assets/data/tool-ui/dns_query.dat index cb8000d..8a636a0 100644 Binary files a/assets/data/tool-ui/dns_query.dat and b/assets/data/tool-ui/dns_query.dat differ diff --git a/assets/data/tool-ui/dns_record_lookup.dat b/assets/data/tool-ui/dns_record_lookup.dat index 4a9a7a8..dd130e0 100644 Binary files a/assets/data/tool-ui/dns_record_lookup.dat and b/assets/data/tool-ui/dns_record_lookup.dat differ diff --git a/assets/data/tool-ui/domain_extractor.dat b/assets/data/tool-ui/domain_extractor.dat index 07b4f80..8de39e9 100644 Binary files a/assets/data/tool-ui/domain_extractor.dat and b/assets/data/tool-ui/domain_extractor.dat differ diff --git a/assets/data/tool-ui/domain_price.dat b/assets/data/tool-ui/domain_price.dat index 96864b5..dcb1c18 100644 Binary files a/assets/data/tool-ui/domain_price.dat and b/assets/data/tool-ui/domain_price.dat differ diff --git a/assets/data/tool-ui/duplicate_detector.dat b/assets/data/tool-ui/duplicate_detector.dat index 388c78d..0ca8574 100644 Binary files a/assets/data/tool-ui/duplicate_detector.dat and b/assets/data/tool-ui/duplicate_detector.dat differ diff --git a/assets/data/tool-ui/earthquake_info.dat b/assets/data/tool-ui/earthquake_info.dat index 60a1438..3ec0b6a 100644 Binary files a/assets/data/tool-ui/earthquake_info.dat and b/assets/data/tool-ui/earthquake_info.dat differ diff --git a/assets/data/tool-ui/extension_extractor.dat b/assets/data/tool-ui/extension_extractor.dat index 2260770..68cfb8a 100644 Binary files a/assets/data/tool-ui/extension_extractor.dat and b/assets/data/tool-ui/extension_extractor.dat differ diff --git a/assets/data/tool-ui/extension_statistics.dat b/assets/data/tool-ui/extension_statistics.dat index 7641a12..2010661 100644 Binary files a/assets/data/tool-ui/extension_statistics.dat and b/assets/data/tool-ui/extension_statistics.dat differ diff --git a/assets/data/tool-ui/external-tool-host.dat b/assets/data/tool-ui/external-tool-host.dat index 9f4a79c..66afe23 100644 Binary files a/assets/data/tool-ui/external-tool-host.dat and b/assets/data/tool-ui/external-tool-host.dat differ diff --git a/assets/data/tool-ui/file_manifest_builder.dat b/assets/data/tool-ui/file_manifest_builder.dat index 82420e2..a716de3 100644 Binary files a/assets/data/tool-ui/file_manifest_builder.dat and b/assets/data/tool-ui/file_manifest_builder.dat differ diff --git a/assets/data/tool-ui/filename_formatter.dat b/assets/data/tool-ui/filename_formatter.dat index bbad27f..1686779 100644 Binary files a/assets/data/tool-ui/filename_formatter.dat and b/assets/data/tool-ui/filename_formatter.dat differ diff --git a/assets/data/tool-ui/football_news.dat b/assets/data/tool-ui/football_news.dat index 55148f3..2937201 100644 Binary files a/assets/data/tool-ui/football_news.dat and b/assets/data/tool-ui/football_news.dat differ diff --git a/assets/data/tool-ui/form_urlencoded_builder.dat b/assets/data/tool-ui/form_urlencoded_builder.dat index 3b8442a..be6748f 100644 Binary files a/assets/data/tool-ui/form_urlencoded_builder.dat and b/assets/data/tool-ui/form_urlencoded_builder.dat differ diff --git a/assets/data/tool-ui/gcd_lcm_calculator.dat b/assets/data/tool-ui/gcd_lcm_calculator.dat index 5393016..8685efe 100644 Binary files a/assets/data/tool-ui/gcd_lcm_calculator.dat and b/assets/data/tool-ui/gcd_lcm_calculator.dat differ diff --git a/assets/data/tool-ui/gold_price.dat b/assets/data/tool-ui/gold_price.dat index dcc1046..83b7ad2 100644 Binary files a/assets/data/tool-ui/gold_price.dat and b/assets/data/tool-ui/gold_price.dat differ diff --git a/assets/data/tool-ui/hardware.dat b/assets/data/tool-ui/hardware.dat index d5abe59..7a2932b 100644 Binary files a/assets/data/tool-ui/hardware.dat and b/assets/data/tool-ui/hardware.dat differ diff --git a/assets/data/tool-ui/hash_generator.dat b/assets/data/tool-ui/hash_generator.dat index 43efb45..d86aa14 100644 Binary files a/assets/data/tool-ui/hash_generator.dat and b/assets/data/tool-ui/hash_generator.dat differ diff --git a/assets/data/tool-ui/hash_manifest_builder.dat b/assets/data/tool-ui/hash_manifest_builder.dat index 9e17b9b..50f80c4 100644 Binary files a/assets/data/tool-ui/hash_manifest_builder.dat and b/assets/data/tool-ui/hash_manifest_builder.dat differ diff --git a/assets/data/tool-ui/hash_manifest_verify.dat b/assets/data/tool-ui/hash_manifest_verify.dat index b9dbc10..904b869 100644 Binary files a/assets/data/tool-ui/hash_manifest_verify.dat and b/assets/data/tool-ui/hash_manifest_verify.dat differ diff --git a/assets/data/tool-ui/history_today.dat b/assets/data/tool-ui/history_today.dat index 5aeb681..d385850 100644 Binary files a/assets/data/tool-ui/history_today.dat and b/assets/data/tool-ui/history_today.dat differ diff --git a/assets/data/tool-ui/hmac_generator.dat b/assets/data/tool-ui/hmac_generator.dat index 0e7a5b6..5e22323 100644 Binary files a/assets/data/tool-ui/hmac_generator.dat and b/assets/data/tool-ui/hmac_generator.dat differ diff --git a/assets/data/tool-ui/hotboard.dat b/assets/data/tool-ui/hotboard.dat index b3618de..0703a36 100644 Binary files a/assets/data/tool-ui/hotboard.dat and b/assets/data/tool-ui/hotboard.dat differ diff --git a/assets/data/tool-ui/html_entity.dat b/assets/data/tool-ui/html_entity.dat index a6fd70f..70675a4 100644 Binary files a/assets/data/tool-ui/html_entity.dat and b/assets/data/tool-ui/html_entity.dat differ diff --git a/assets/data/tool-ui/html_js_playground.dat b/assets/data/tool-ui/html_js_playground.dat index 44bdeb1..070a8d3 100644 Binary files a/assets/data/tool-ui/html_js_playground.dat and b/assets/data/tool-ui/html_js_playground.dat differ diff --git a/assets/data/tool-ui/html_minifier.dat b/assets/data/tool-ui/html_minifier.dat index aaeccf6..b851d87 100644 Binary files a/assets/data/tool-ui/html_minifier.dat and b/assets/data/tool-ui/html_minifier.dat differ diff --git a/assets/data/tool-ui/html_text_extractor.dat b/assets/data/tool-ui/html_text_extractor.dat index d5571fa..324ff74 100644 Binary files a/assets/data/tool-ui/html_text_extractor.dat and b/assets/data/tool-ui/html_text_extractor.dat differ diff --git a/assets/data/tool-ui/http_diagnostic.dat b/assets/data/tool-ui/http_diagnostic.dat index ead112d..379a921 100644 Binary files a/assets/data/tool-ui/http_diagnostic.dat and b/assets/data/tool-ui/http_diagnostic.dat differ diff --git a/assets/data/tool-ui/http_status_lookup.dat b/assets/data/tool-ui/http_status_lookup.dat index d0cb74c..3c178cc 100644 Binary files a/assets/data/tool-ui/http_status_lookup.dat and b/assets/data/tool-ui/http_status_lookup.dat differ diff --git a/assets/data/tool-ui/image_metadata_inspector.dat b/assets/data/tool-ui/image_metadata_inspector.dat index 939c358..1781a53 100644 Binary files a/assets/data/tool-ui/image_metadata_inspector.dat and b/assets/data/tool-ui/image_metadata_inspector.dat differ diff --git a/assets/data/tool-ui/image_processor.dat b/assets/data/tool-ui/image_processor.dat index 8bf0686..ae3da93 100644 Binary files a/assets/data/tool-ui/image_processor.dat and b/assets/data/tool-ui/image_processor.dat differ diff --git a/assets/data/tool-ui/indexed_rename_preview.dat b/assets/data/tool-ui/indexed_rename_preview.dat index 180b64a..314be89 100644 Binary files a/assets/data/tool-ui/indexed_rename_preview.dat and b/assets/data/tool-ui/indexed_rename_preview.dat differ diff --git a/assets/data/tool-ui/ip_info.dat b/assets/data/tool-ui/ip_info.dat index 3a0cf99..810546d 100644 Binary files a/assets/data/tool-ui/ip_info.dat and b/assets/data/tool-ui/ip_info.dat differ diff --git a/assets/data/tool-ui/ip_lookup.dat b/assets/data/tool-ui/ip_lookup.dat index eef2ea9..1fac6f1 100644 Binary files a/assets/data/tool-ui/ip_lookup.dat and b/assets/data/tool-ui/ip_lookup.dat differ diff --git a/assets/data/tool-ui/js_obfuscator.dat b/assets/data/tool-ui/js_obfuscator.dat index 90f5692..bfa01b2 100644 Binary files a/assets/data/tool-ui/js_obfuscator.dat and b/assets/data/tool-ui/js_obfuscator.dat differ diff --git a/assets/data/tool-ui/json_formatter.dat b/assets/data/tool-ui/json_formatter.dat index b0b2b93..771582c 100644 Binary files a/assets/data/tool-ui/json_formatter.dat and b/assets/data/tool-ui/json_formatter.dat differ diff --git a/assets/data/tool-ui/json_path_helper.dat b/assets/data/tool-ui/json_path_helper.dat index 48e0916..f76cd74 100644 Binary files a/assets/data/tool-ui/json_path_helper.dat and b/assets/data/tool-ui/json_path_helper.dat differ diff --git a/assets/data/tool-ui/jwt_decoder.dat b/assets/data/tool-ui/jwt_decoder.dat index 1ec58c0..19a77db 100644 Binary files a/assets/data/tool-ui/jwt_decoder.dat and b/assets/data/tool-ui/jwt_decoder.dat differ diff --git a/assets/data/tool-ui/key_value_converter.dat b/assets/data/tool-ui/key_value_converter.dat index 11958dc..d9d7493 100644 Binary files a/assets/data/tool-ui/key_value_converter.dat and b/assets/data/tool-ui/key_value_converter.dat differ diff --git a/assets/data/tool-ui/keyword_counter.dat b/assets/data/tool-ui/keyword_counter.dat index 94f3cac..ab21067 100644 Binary files a/assets/data/tool-ui/keyword_counter.dat and b/assets/data/tool-ui/keyword_counter.dat differ diff --git a/assets/data/tool-ui/length_converter.dat b/assets/data/tool-ui/length_converter.dat index 0e1647d..d631899 100644 Binary files a/assets/data/tool-ui/length_converter.dat and b/assets/data/tool-ui/length_converter.dat differ diff --git a/assets/data/tool-ui/line_column_transformer.dat b/assets/data/tool-ui/line_column_transformer.dat index c774366..ffb9e77 100644 Binary files a/assets/data/tool-ui/line_column_transformer.dat and b/assets/data/tool-ui/line_column_transformer.dat differ diff --git a/assets/data/tool-ui/line_length_analyzer.dat b/assets/data/tool-ui/line_length_analyzer.dat index 6f47f65..1b7c84b 100644 Binary files a/assets/data/tool-ui/line_length_analyzer.dat and b/assets/data/tool-ui/line_length_analyzer.dat differ diff --git a/assets/data/tool-ui/line_pair_zipper.dat b/assets/data/tool-ui/line_pair_zipper.dat index c342b3e..976d4ee 100644 Binary files a/assets/data/tool-ui/line_pair_zipper.dat and b/assets/data/tool-ui/line_pair_zipper.dat differ diff --git a/assets/data/tool-ui/list_chunker.dat b/assets/data/tool-ui/list_chunker.dat index e5728e8..1f61c6e 100644 Binary files a/assets/data/tool-ui/list_chunker.dat and b/assets/data/tool-ui/list_chunker.dat differ diff --git a/assets/data/tool-ui/list_difference.dat b/assets/data/tool-ui/list_difference.dat index d04ea92..db5acdd 100644 Binary files a/assets/data/tool-ui/list_difference.dat and b/assets/data/tool-ui/list_difference.dat differ diff --git a/assets/data/tool-ui/list_group_statistics.dat b/assets/data/tool-ui/list_group_statistics.dat index 10ce050..e5b6e0d 100644 Binary files a/assets/data/tool-ui/list_group_statistics.dat and b/assets/data/tool-ui/list_group_statistics.dat differ diff --git a/assets/data/tool-ui/list_intersection.dat b/assets/data/tool-ui/list_intersection.dat index c2073af..a6b13da 100644 Binary files a/assets/data/tool-ui/list_intersection.dat and b/assets/data/tool-ui/list_intersection.dat differ diff --git a/assets/data/tool-ui/list_sampler.dat b/assets/data/tool-ui/list_sampler.dat index 95185ac..2331898 100644 Binary files a/assets/data/tool-ui/list_sampler.dat and b/assets/data/tool-ui/list_sampler.dat differ diff --git a/assets/data/tool-ui/list_union.dat b/assets/data/tool-ui/list_union.dat index cb93739..37b77a4 100644 Binary files a/assets/data/tool-ui/list_union.dat and b/assets/data/tool-ui/list_union.dat differ diff --git a/assets/data/tool-ui/loan_emi_calculator.dat b/assets/data/tool-ui/loan_emi_calculator.dat index 401e3f8..f2ddd3f 100644 Binary files a/assets/data/tool-ui/loan_emi_calculator.dat and b/assets/data/tool-ui/loan_emi_calculator.dat differ diff --git a/assets/data/tool-ui/magic_number_lookup.dat b/assets/data/tool-ui/magic_number_lookup.dat index d6aff9f..67b7a5f 100644 Binary files a/assets/data/tool-ui/magic_number_lookup.dat and b/assets/data/tool-ui/magic_number_lookup.dat differ diff --git a/assets/data/tool-ui/manifest_deduplicator.dat b/assets/data/tool-ui/manifest_deduplicator.dat index f978737..aac97de 100644 Binary files a/assets/data/tool-ui/manifest_deduplicator.dat and b/assets/data/tool-ui/manifest_deduplicator.dat differ diff --git a/assets/data/tool-ui/manifest_sorter.dat b/assets/data/tool-ui/manifest_sorter.dat index a7cafab..c915def 100644 Binary files a/assets/data/tool-ui/manifest_sorter.dat and b/assets/data/tool-ui/manifest_sorter.dat differ diff --git a/assets/data/tool-ui/manifest_template_export.dat b/assets/data/tool-ui/manifest_template_export.dat index 49f4b20..3636855 100644 Binary files a/assets/data/tool-ui/manifest_template_export.dat and b/assets/data/tool-ui/manifest_template_export.dat differ diff --git a/assets/data/tool-ui/markdown_list_cleaner.dat b/assets/data/tool-ui/markdown_list_cleaner.dat index cedc97f..9afed2c 100644 Binary files a/assets/data/tool-ui/markdown_list_cleaner.dat and b/assets/data/tool-ui/markdown_list_cleaner.dat differ diff --git a/assets/data/tool-ui/markdown_preview.dat b/assets/data/tool-ui/markdown_preview.dat index 563307a..18ae895 100644 Binary files a/assets/data/tool-ui/markdown_preview.dat and b/assets/data/tool-ui/markdown_preview.dat differ diff --git a/assets/data/tool-ui/markdown_table_normalizer.dat b/assets/data/tool-ui/markdown_table_normalizer.dat index 39a4433..7fad412 100644 Binary files a/assets/data/tool-ui/markdown_table_normalizer.dat and b/assets/data/tool-ui/markdown_table_normalizer.dat differ diff --git a/assets/data/tool-ui/media_player.dat b/assets/data/tool-ui/media_player.dat index 5cb7bf0..4a1d71d 100644 Binary files a/assets/data/tool-ui/media_player.dat and b/assets/data/tool-ui/media_player.dat differ diff --git a/assets/data/tool-ui/median_calculator.dat b/assets/data/tool-ui/median_calculator.dat index 2d2829d..f24a534 100644 Binary files a/assets/data/tool-ui/median_calculator.dat and b/assets/data/tool-ui/median_calculator.dat differ diff --git a/assets/data/tool-ui/mime_lookup.dat b/assets/data/tool-ui/mime_lookup.dat index 00d173d..4ed4ffd 100644 Binary files a/assets/data/tool-ui/mime_lookup.dat and b/assets/data/tool-ui/mime_lookup.dat differ diff --git a/assets/data/tool-ui/movie_box_office.dat b/assets/data/tool-ui/movie_box_office.dat index 23c903d..eecfcdd 100644 Binary files a/assets/data/tool-ui/movie_box_office.dat and b/assets/data/tool-ui/movie_box_office.dat differ diff --git a/assets/data/tool-ui/music.dat b/assets/data/tool-ui/music.dat index fcb9f35..083173c 100644 Binary files a/assets/data/tool-ui/music.dat and b/assets/data/tool-ui/music.dat differ diff --git a/assets/data/tool-ui/nex_ace_optimize.dat b/assets/data/tool-ui/nex_ace_optimize.dat index 7fa7c8b..80d8841 100644 Binary files a/assets/data/tool-ui/nex_ace_optimize.dat and b/assets/data/tool-ui/nex_ace_optimize.dat differ diff --git a/assets/data/tool-ui/nex_audio_eq.dat b/assets/data/tool-ui/nex_audio_eq.dat index aa56ba3..902b9ab 100644 Binary files a/assets/data/tool-ui/nex_audio_eq.dat and b/assets/data/tool-ui/nex_audio_eq.dat differ diff --git a/assets/data/tool-ui/nex_crosshair.dat b/assets/data/tool-ui/nex_crosshair.dat index a5462f1..e1657a2 100644 Binary files a/assets/data/tool-ui/nex_crosshair.dat and b/assets/data/tool-ui/nex_crosshair.dat differ diff --git a/assets/data/tool-ui/nex_delta_force.dat b/assets/data/tool-ui/nex_delta_force.dat index 2875104..26a39b9 100644 Binary files a/assets/data/tool-ui/nex_delta_force.dat and b/assets/data/tool-ui/nex_delta_force.dat differ diff --git a/assets/data/tool-ui/nex_disk_health.dat b/assets/data/tool-ui/nex_disk_health.dat index 3572239..d7e4e11 100644 Binary files a/assets/data/tool-ui/nex_disk_health.dat and b/assets/data/tool-ui/nex_disk_health.dat differ diff --git a/assets/data/tool-ui/nex_display_filter.dat b/assets/data/tool-ui/nex_display_filter.dat index 0de213e..205a95f 100644 Binary files a/assets/data/tool-ui/nex_display_filter.dat and b/assets/data/tool-ui/nex_display_filter.dat differ diff --git a/assets/data/tool-ui/nex_epic_free.dat b/assets/data/tool-ui/nex_epic_free.dat index 9a2d28c..93d00a9 100644 Binary files a/assets/data/tool-ui/nex_epic_free.dat and b/assets/data/tool-ui/nex_epic_free.dat differ diff --git a/assets/data/tool-ui/nex_event_log_diagnostics.dat b/assets/data/tool-ui/nex_event_log_diagnostics.dat index 302ff2d..d63eac0 100644 Binary files a/assets/data/tool-ui/nex_event_log_diagnostics.dat and b/assets/data/tool-ui/nex_event_log_diagnostics.dat differ diff --git a/assets/data/tool-ui/nex_file_lock_analysis.dat b/assets/data/tool-ui/nex_file_lock_analysis.dat index ba2e804..475d9c9 100644 Binary files a/assets/data/tool-ui/nex_file_lock_analysis.dat and b/assets/data/tool-ui/nex_file_lock_analysis.dat differ diff --git a/assets/data/tool-ui/nex_gpu_rename.dat b/assets/data/tool-ui/nex_gpu_rename.dat index ff89ca9..3365327 100644 Binary files a/assets/data/tool-ui/nex_gpu_rename.dat and b/assets/data/tool-ui/nex_gpu_rename.dat differ diff --git a/assets/data/tool-ui/nex_installed_apps.dat b/assets/data/tool-ui/nex_installed_apps.dat index d37ceaf..4a553cb 100644 Binary files a/assets/data/tool-ui/nex_installed_apps.dat and b/assets/data/tool-ui/nex_installed_apps.dat differ diff --git a/assets/data/tool-ui/nex_memory_cleanup.dat b/assets/data/tool-ui/nex_memory_cleanup.dat index af9b93a..ccaf796 100644 Binary files a/assets/data/tool-ui/nex_memory_cleanup.dat and b/assets/data/tool-ui/nex_memory_cleanup.dat differ diff --git a/assets/data/tool-ui/nex_memory_limit.dat b/assets/data/tool-ui/nex_memory_limit.dat index d5aac54..5812fd0 100644 Binary files a/assets/data/tool-ui/nex_memory_limit.dat and b/assets/data/tool-ui/nex_memory_limit.dat differ diff --git a/assets/data/tool-ui/nex_mood.dat b/assets/data/tool-ui/nex_mood.dat index 45b4d5f..b704f5e 100644 Binary files a/assets/data/tool-ui/nex_mood.dat and b/assets/data/tool-ui/nex_mood.dat differ diff --git a/assets/data/tool-ui/nex_network_optimizer.dat b/assets/data/tool-ui/nex_network_optimizer.dat index 2c3a1ba..727ddb0 100644 Binary files a/assets/data/tool-ui/nex_network_optimizer.dat and b/assets/data/tool-ui/nex_network_optimizer.dat differ diff --git a/assets/data/tool-ui/nex_nvidia_driver.dat b/assets/data/tool-ui/nex_nvidia_driver.dat index 2323e9d..64e61b4 100644 Binary files a/assets/data/tool-ui/nex_nvidia_driver.dat and b/assets/data/tool-ui/nex_nvidia_driver.dat differ diff --git a/assets/data/tool-ui/nex_optional_features.dat b/assets/data/tool-ui/nex_optional_features.dat index 7757b17..f2d5e74 100644 Binary files a/assets/data/tool-ui/nex_optional_features.dat and b/assets/data/tool-ui/nex_optional_features.dat differ diff --git a/assets/data/tool-ui/nex_other_gun_codes.dat b/assets/data/tool-ui/nex_other_gun_codes.dat index f11b362..bccd79b 100644 Binary files a/assets/data/tool-ui/nex_other_gun_codes.dat and b/assets/data/tool-ui/nex_other_gun_codes.dat differ diff --git a/assets/data/tool-ui/nex_overlay.dat b/assets/data/tool-ui/nex_overlay.dat index 50c3d7a..c55b805 100644 Binary files a/assets/data/tool-ui/nex_overlay.dat and b/assets/data/tool-ui/nex_overlay.dat differ diff --git a/assets/data/tool-ui/nex_peripheral_optimize.dat b/assets/data/tool-ui/nex_peripheral_optimize.dat index 7f734cc..1444255 100644 Binary files a/assets/data/tool-ui/nex_peripheral_optimize.dat and b/assets/data/tool-ui/nex_peripheral_optimize.dat differ diff --git a/assets/data/tool-ui/nex_power_management.dat b/assets/data/tool-ui/nex_power_management.dat index 0e61b85..0682aff 100644 Binary files a/assets/data/tool-ui/nex_power_management.dat and b/assets/data/tool-ui/nex_power_management.dat differ diff --git a/assets/data/tool-ui/nex_resolution_converter.dat b/assets/data/tool-ui/nex_resolution_converter.dat index 9b96dc8..c1b1428 100644 Binary files a/assets/data/tool-ui/nex_resolution_converter.dat and b/assets/data/tool-ui/nex_resolution_converter.dat differ diff --git a/assets/data/tool-ui/nex_restore_points.dat b/assets/data/tool-ui/nex_restore_points.dat index b0986a5..8a0687a 100644 Binary files a/assets/data/tool-ui/nex_restore_points.dat and b/assets/data/tool-ui/nex_restore_points.dat differ diff --git a/assets/data/tool-ui/nex_shader_cache.dat b/assets/data/tool-ui/nex_shader_cache.dat index 9263f15..26a9799 100644 Binary files a/assets/data/tool-ui/nex_shader_cache.dat and b/assets/data/tool-ui/nex_shader_cache.dat differ diff --git a/assets/data/tool-ui/nex_startup_manager.dat b/assets/data/tool-ui/nex_startup_manager.dat index 6fd8050..f4cd2ad 100644 Binary files a/assets/data/tool-ui/nex_startup_manager.dat and b/assets/data/tool-ui/nex_startup_manager.dat differ diff --git a/assets/data/tool-ui/nex_storage_clean.dat b/assets/data/tool-ui/nex_storage_clean.dat index 8170d6b..51bf004 100644 Binary files a/assets/data/tool-ui/nex_storage_clean.dat and b/assets/data/tool-ui/nex_storage_clean.dat differ diff --git a/assets/data/tool-ui/nex_system_optimizer.dat b/assets/data/tool-ui/nex_system_optimizer.dat index c91827b..8c6e8bd 100644 Binary files a/assets/data/tool-ui/nex_system_optimizer.dat and b/assets/data/tool-ui/nex_system_optimizer.dat differ diff --git a/assets/data/tool-ui/nex_third_party_tools.dat b/assets/data/tool-ui/nex_third_party_tools.dat index f1aaae4..ac9fc80 100644 Binary files a/assets/data/tool-ui/nex_third_party_tools.dat and b/assets/data/tool-ui/nex_third_party_tools.dat differ diff --git a/assets/data/tool-ui/nex_tray_island.dat b/assets/data/tool-ui/nex_tray_island.dat index a72d04b..24bf3f3 100644 Binary files a/assets/data/tool-ui/nex_tray_island.dat and b/assets/data/tool-ui/nex_tray_island.dat differ diff --git a/assets/data/tool-ui/nex_vertical_overlay.dat b/assets/data/tool-ui/nex_vertical_overlay.dat index a6e06c3..6fead1f 100644 Binary files a/assets/data/tool-ui/nex_vertical_overlay.dat and b/assets/data/tool-ui/nex_vertical_overlay.dat differ diff --git a/assets/data/tool-ui/nex_windows_repair.dat b/assets/data/tool-ui/nex_windows_repair.dat index 2337b6c..b33cc92 100644 Binary files a/assets/data/tool-ui/nex_windows_repair.dat and b/assets/data/tool-ui/nex_windows_repair.dat differ diff --git a/assets/data/tool-ui/nex_windows_update.dat b/assets/data/tool-ui/nex_windows_update.dat index 262bd12..d20b823 100644 Binary files a/assets/data/tool-ui/nex_windows_update.dat and b/assets/data/tool-ui/nex_windows_update.dat differ diff --git a/assets/data/tool-ui/number_base.dat b/assets/data/tool-ui/number_base.dat index aa7564d..5ea5b97 100644 Binary files a/assets/data/tool-ui/number_base.dat and b/assets/data/tool-ui/number_base.dat differ diff --git a/assets/data/tool-ui/number_base_converter.dat b/assets/data/tool-ui/number_base_converter.dat index 22c658f..825ea87 100644 Binary files a/assets/data/tool-ui/number_base_converter.dat and b/assets/data/tool-ui/number_base_converter.dat differ diff --git a/assets/data/tool-ui/oil_price.dat b/assets/data/tool-ui/oil_price.dat index 024e192..d6e39f6 100644 Binary files a/assets/data/tool-ui/oil_price.dat and b/assets/data/tool-ui/oil_price.dat differ diff --git a/assets/data/tool-ui/online_tools.dat b/assets/data/tool-ui/online_tools.dat new file mode 100644 index 0000000..874b5f6 Binary files /dev/null and b/assets/data/tool-ui/online_tools.dat differ diff --git a/assets/data/tool-ui/optimization.dat b/assets/data/tool-ui/optimization.dat index f458688..1e97362 100644 Binary files a/assets/data/tool-ui/optimization.dat and b/assets/data/tool-ui/optimization.dat differ diff --git a/assets/data/tool-ui/password_generator.dat b/assets/data/tool-ui/password_generator.dat index 7d466be..89e1226 100644 Binary files a/assets/data/tool-ui/password_generator.dat and b/assets/data/tool-ui/password_generator.dat differ diff --git a/assets/data/tool-ui/path_breakdown.dat b/assets/data/tool-ui/path_breakdown.dat index c5c2668..a6a3231 100644 Binary files a/assets/data/tool-ui/path_breakdown.dat and b/assets/data/tool-ui/path_breakdown.dat differ diff --git a/assets/data/tool-ui/path_normalizer.dat b/assets/data/tool-ui/path_normalizer.dat index a6ce35b..b2c9cab 100644 Binary files a/assets/data/tool-ui/path_normalizer.dat and b/assets/data/tool-ui/path_normalizer.dat differ diff --git a/assets/data/tool-ui/pc_benchmark.dat b/assets/data/tool-ui/pc_benchmark.dat index 96160f7..daedb56 100644 Binary files a/assets/data/tool-ui/pc_benchmark.dat and b/assets/data/tool-ui/pc_benchmark.dat differ diff --git a/assets/data/tool-ui/pear_ai_dish_recognition.dat b/assets/data/tool-ui/pear_ai_dish_recognition.dat new file mode 100644 index 0000000..3cf0edd Binary files /dev/null and b/assets/data/tool-ui/pear_ai_dish_recognition.dat differ diff --git a/assets/data/tool-ui/pear_ai_image_describe.dat b/assets/data/tool-ui/pear_ai_image_describe.dat new file mode 100644 index 0000000..6a10791 Binary files /dev/null and b/assets/data/tool-ui/pear_ai_image_describe.dat differ diff --git a/assets/data/tool-ui/pear_ai_image_detection.dat b/assets/data/tool-ui/pear_ai_image_detection.dat new file mode 100644 index 0000000..8852ab8 Binary files /dev/null and b/assets/data/tool-ui/pear_ai_image_detection.dat differ diff --git a/assets/data/tool-ui/pear_ai_text_safety.dat b/assets/data/tool-ui/pear_ai_text_safety.dat new file mode 100644 index 0000000..323fa26 Binary files /dev/null and b/assets/data/tool-ui/pear_ai_text_safety.dat differ diff --git a/assets/data/tool-ui/pear_answers_book.dat b/assets/data/tool-ui/pear_answers_book.dat new file mode 100644 index 0000000..b8f5f6f Binary files /dev/null and b/assets/data/tool-ui/pear_answers_book.dat differ diff --git a/assets/data/tool-ui/pear_audio_transcript.dat b/assets/data/tool-ui/pear_audio_transcript.dat new file mode 100644 index 0000000..da86fdd Binary files /dev/null and b/assets/data/tool-ui/pear_audio_transcript.dat differ diff --git a/assets/data/tool-ui/pear_award.dat b/assets/data/tool-ui/pear_award.dat new file mode 100644 index 0000000..3809bfa Binary files /dev/null and b/assets/data/tool-ui/pear_award.dat differ diff --git a/assets/data/tool-ui/pear_baidu_place.dat b/assets/data/tool-ui/pear_baidu_place.dat new file mode 100644 index 0000000..a2b1ad6 Binary files /dev/null and b/assets/data/tool-ui/pear_baidu_place.dat differ diff --git a/assets/data/tool-ui/pear_blood_type.dat b/assets/data/tool-ui/pear_blood_type.dat new file mode 100644 index 0000000..d35152c Binary files /dev/null and b/assets/data/tool-ui/pear_blood_type.dat differ diff --git a/assets/data/tool-ui/pear_brainteaser.dat b/assets/data/tool-ui/pear_brainteaser.dat new file mode 100644 index 0000000..cf1c13d Binary files /dev/null and b/assets/data/tool-ui/pear_brainteaser.dat differ diff --git a/assets/data/tool-ui/pear_car_price.dat b/assets/data/tool-ui/pear_car_price.dat new file mode 100644 index 0000000..35e7128 Binary files /dev/null and b/assets/data/tool-ui/pear_car_price.dat differ diff --git a/assets/data/tool-ui/pear_certificate.dat b/assets/data/tool-ui/pear_certificate.dat new file mode 100644 index 0000000..bf2ee7c Binary files /dev/null and b/assets/data/tool-ui/pear_certificate.dat differ diff --git a/assets/data/tool-ui/pear_chinese_conversion.dat b/assets/data/tool-ui/pear_chinese_conversion.dat new file mode 100644 index 0000000..bfe382a Binary files /dev/null and b/assets/data/tool-ui/pear_chinese_conversion.dat differ diff --git a/assets/data/tool-ui/pear_classic_joke.dat b/assets/data/tool-ui/pear_classic_joke.dat new file mode 100644 index 0000000..0a179e7 Binary files /dev/null and b/assets/data/tool-ui/pear_classic_joke.dat differ diff --git a/assets/data/tool-ui/pear_college.dat b/assets/data/tool-ui/pear_college.dat new file mode 100644 index 0000000..5217eaf Binary files /dev/null and b/assets/data/tool-ui/pear_college.dat differ diff --git a/assets/data/tool-ui/pear_constellation.dat b/assets/data/tool-ui/pear_constellation.dat new file mode 100644 index 0000000..ded931e Binary files /dev/null and b/assets/data/tool-ui/pear_constellation.dat differ diff --git a/assets/data/tool-ui/pear_cookbook.dat b/assets/data/tool-ui/pear_cookbook.dat new file mode 100644 index 0000000..2eb9b64 Binary files /dev/null and b/assets/data/tool-ui/pear_cookbook.dat differ diff --git a/assets/data/tool-ui/pear_countdown.dat b/assets/data/tool-ui/pear_countdown.dat new file mode 100644 index 0000000..2823aad Binary files /dev/null and b/assets/data/tool-ui/pear_countdown.dat differ diff --git a/assets/data/tool-ui/pear_country_population.dat b/assets/data/tool-ui/pear_country_population.dat new file mode 100644 index 0000000..33d4c48 Binary files /dev/null and b/assets/data/tool-ui/pear_country_population.dat differ diff --git a/assets/data/tool-ui/pear_couplet.dat b/assets/data/tool-ui/pear_couplet.dat new file mode 100644 index 0000000..3dd1f5f Binary files /dev/null and b/assets/data/tool-ui/pear_couplet.dat differ diff --git a/assets/data/tool-ui/pear_daily_60_image.dat b/assets/data/tool-ui/pear_daily_60_image.dat new file mode 100644 index 0000000..41e3ebd Binary files /dev/null and b/assets/data/tool-ui/pear_daily_60_image.dat differ diff --git a/assets/data/tool-ui/pear_dujitang.dat b/assets/data/tool-ui/pear_dujitang.dat new file mode 100644 index 0000000..75fef8f Binary files /dev/null and b/assets/data/tool-ui/pear_dujitang.dat differ diff --git a/assets/data/tool-ui/pear_embeddings.dat b/assets/data/tool-ui/pear_embeddings.dat new file mode 100644 index 0000000..1146c23 Binary files /dev/null and b/assets/data/tool-ui/pear_embeddings.dat differ diff --git a/assets/data/tool-ui/pear_exchange_rate.dat b/assets/data/tool-ui/pear_exchange_rate.dat new file mode 100644 index 0000000..116845e Binary files /dev/null and b/assets/data/tool-ui/pear_exchange_rate.dat differ diff --git a/assets/data/tool-ui/pear_funny_joke.dat b/assets/data/tool-ui/pear_funny_joke.dat new file mode 100644 index 0000000..a97a0bf Binary files /dev/null and b/assets/data/tool-ui/pear_funny_joke.dat differ diff --git a/assets/data/tool-ui/pear_gold_price.dat b/assets/data/tool-ui/pear_gold_price.dat new file mode 100644 index 0000000..1083628 Binary files /dev/null and b/assets/data/tool-ui/pear_gold_price.dat differ diff --git a/assets/data/tool-ui/pear_historical_person.dat b/assets/data/tool-ui/pear_historical_person.dat new file mode 100644 index 0000000..aaf6543 Binary files /dev/null and b/assets/data/tool-ui/pear_historical_person.dat differ diff --git a/assets/data/tool-ui/pear_hitokoto.dat b/assets/data/tool-ui/pear_hitokoto.dat new file mode 100644 index 0000000..9b47f7f Binary files /dev/null and b/assets/data/tool-ui/pear_hitokoto.dat differ diff --git a/assets/data/tool-ui/pear_hot_60_image.dat b/assets/data/tool-ui/pear_hot_60_image.dat new file mode 100644 index 0000000..5373902 Binary files /dev/null and b/assets/data/tool-ui/pear_hot_60_image.dat differ diff --git a/assets/data/tool-ui/pear_icp.dat b/assets/data/tool-ui/pear_icp.dat new file mode 100644 index 0000000..f01ed28 Binary files /dev/null and b/assets/data/tool-ui/pear_icp.dat differ diff --git a/assets/data/tool-ui/pear_image_prompt.dat b/assets/data/tool-ui/pear_image_prompt.dat new file mode 100644 index 0000000..e1eab1a Binary files /dev/null and b/assets/data/tool-ui/pear_image_prompt.dat differ diff --git a/assets/data/tool-ui/pear_ip_details.dat b/assets/data/tool-ui/pear_ip_details.dat new file mode 100644 index 0000000..4bba3d1 Binary files /dev/null and b/assets/data/tool-ui/pear_ip_details.dat differ diff --git a/assets/data/tool-ui/pear_keyword_guide.dat b/assets/data/tool-ui/pear_keyword_guide.dat new file mode 100644 index 0000000..689f640 Binary files /dev/null and b/assets/data/tool-ui/pear_keyword_guide.dat differ diff --git a/assets/data/tool-ui/pear_kfc.dat b/assets/data/tool-ui/pear_kfc.dat new file mode 100644 index 0000000..c752808 Binary files /dev/null and b/assets/data/tool-ui/pear_kfc.dat differ diff --git a/assets/data/tool-ui/pear_life_calculator.dat b/assets/data/tool-ui/pear_life_calculator.dat new file mode 100644 index 0000000..398f5a7 Binary files /dev/null and b/assets/data/tool-ui/pear_life_calculator.dat differ diff --git a/assets/data/tool-ui/pear_llm_reader.dat b/assets/data/tool-ui/pear_llm_reader.dat new file mode 100644 index 0000000..134a955 Binary files /dev/null and b/assets/data/tool-ui/pear_llm_reader.dat differ diff --git a/assets/data/tool-ui/pear_lottery.dat b/assets/data/tool-ui/pear_lottery.dat new file mode 100644 index 0000000..e10f9c5 Binary files /dev/null and b/assets/data/tool-ui/pear_lottery.dat differ diff --git a/assets/data/tool-ui/pear_love_quote.dat b/assets/data/tool-ui/pear_love_quote.dat new file mode 100644 index 0000000..b5e570c Binary files /dev/null and b/assets/data/tool-ui/pear_love_quote.dat differ diff --git a/assets/data/tool-ui/pear_love_reply.dat b/assets/data/tool-ui/pear_love_reply.dat new file mode 100644 index 0000000..83e533c Binary files /dev/null and b/assets/data/tool-ui/pear_love_reply.dat differ diff --git a/assets/data/tool-ui/pear_mean_search.dat b/assets/data/tool-ui/pear_mean_search.dat new file mode 100644 index 0000000..6be7ee7 Binary files /dev/null and b/assets/data/tool-ui/pear_mean_search.dat differ diff --git a/assets/data/tool-ui/pear_movie_lines.dat b/assets/data/tool-ui/pear_movie_lines.dat new file mode 100644 index 0000000..3117ee7 Binary files /dev/null and b/assets/data/tool-ui/pear_movie_lines.dat differ diff --git a/assets/data/tool-ui/pear_ocr.dat b/assets/data/tool-ui/pear_ocr.dat new file mode 100644 index 0000000..4812c7a Binary files /dev/null and b/assets/data/tool-ui/pear_ocr.dat differ diff --git a/assets/data/tool-ui/pear_poem.dat b/assets/data/tool-ui/pear_poem.dat new file mode 100644 index 0000000..5170368 Binary files /dev/null and b/assets/data/tool-ui/pear_poem.dat differ diff --git a/assets/data/tool-ui/pear_qq_level_image.dat b/assets/data/tool-ui/pear_qq_level_image.dat new file mode 100644 index 0000000..49308f0 Binary files /dev/null and b/assets/data/tool-ui/pear_qq_level_image.dat differ diff --git a/assets/data/tool-ui/pear_question_search.dat b/assets/data/tool-ui/pear_question_search.dat new file mode 100644 index 0000000..969041e Binary files /dev/null and b/assets/data/tool-ui/pear_question_search.dat differ diff --git a/assets/data/tool-ui/pear_quirky_dialogue.dat b/assets/data/tool-ui/pear_quirky_dialogue.dat new file mode 100644 index 0000000..c81a647 Binary files /dev/null and b/assets/data/tool-ui/pear_quirky_dialogue.dat differ diff --git a/assets/data/tool-ui/pear_rainbow.dat b/assets/data/tool-ui/pear_rainbow.dat new file mode 100644 index 0000000..25c349e Binary files /dev/null and b/assets/data/tool-ui/pear_rainbow.dat differ diff --git a/assets/data/tool-ui/pear_random_song.dat b/assets/data/tool-ui/pear_random_song.dat new file mode 100644 index 0000000..ee50283 Binary files /dev/null and b/assets/data/tool-ui/pear_random_song.dat differ diff --git a/assets/data/tool-ui/pear_random_superpower.dat b/assets/data/tool-ui/pear_random_superpower.dat new file mode 100644 index 0000000..350d3c2 Binary files /dev/null and b/assets/data/tool-ui/pear_random_superpower.dat differ diff --git a/assets/data/tool-ui/pear_ruozhiba.dat b/assets/data/tool-ui/pear_ruozhiba.dat new file mode 100644 index 0000000..c1ab6e1 Binary files /dev/null and b/assets/data/tool-ui/pear_ruozhiba.dat differ diff --git a/assets/data/tool-ui/pear_science_news.dat b/assets/data/tool-ui/pear_science_news.dat new file mode 100644 index 0000000..37e1126 Binary files /dev/null and b/assets/data/tool-ui/pear_science_news.dat differ diff --git a/assets/data/tool-ui/pear_sensitive_words.dat b/assets/data/tool-ui/pear_sensitive_words.dat new file mode 100644 index 0000000..da27be5 Binary files /dev/null and b/assets/data/tool-ui/pear_sensitive_words.dat differ diff --git a/assets/data/tool-ui/pear_short_recovery.dat b/assets/data/tool-ui/pear_short_recovery.dat new file mode 100644 index 0000000..636c4cb Binary files /dev/null and b/assets/data/tool-ui/pear_short_recovery.dat differ diff --git a/assets/data/tool-ui/pear_site_index.dat b/assets/data/tool-ui/pear_site_index.dat new file mode 100644 index 0000000..62b9d94 Binary files /dev/null and b/assets/data/tool-ui/pear_site_index.dat differ diff --git a/assets/data/tool-ui/pear_ssl.dat b/assets/data/tool-ui/pear_ssl.dat new file mode 100644 index 0000000..06ba916 Binary files /dev/null and b/assets/data/tool-ui/pear_ssl.dat differ diff --git a/assets/data/tool-ui/pear_stock.dat b/assets/data/tool-ui/pear_stock.dat new file mode 100644 index 0000000..bb29f46 Binary files /dev/null and b/assets/data/tool-ui/pear_stock.dat differ diff --git a/assets/data/tool-ui/pear_surname_pun.dat b/assets/data/tool-ui/pear_surname_pun.dat new file mode 100644 index 0000000..37baac9 Binary files /dev/null and b/assets/data/tool-ui/pear_surname_pun.dat differ diff --git a/assets/data/tool-ui/pear_synonym_antonym.dat b/assets/data/tool-ui/pear_synonym_antonym.dat new file mode 100644 index 0000000..332b16d Binary files /dev/null and b/assets/data/tool-ui/pear_synonym_antonym.dat differ diff --git a/assets/data/tool-ui/pear_text_tts.dat b/assets/data/tool-ui/pear_text_tts.dat new file mode 100644 index 0000000..dbe4cc6 Binary files /dev/null and b/assets/data/tool-ui/pear_text_tts.dat differ diff --git a/assets/data/tool-ui/pear_today_food.dat b/assets/data/tool-ui/pear_today_food.dat new file mode 100644 index 0000000..7227adb Binary files /dev/null and b/assets/data/tool-ui/pear_today_food.dat differ diff --git a/assets/data/tool-ui/pear_trademark.dat b/assets/data/tool-ui/pear_trademark.dat new file mode 100644 index 0000000..1ba3954 Binary files /dev/null and b/assets/data/tool-ui/pear_trademark.dat differ diff --git a/assets/data/tool-ui/pear_translation.dat b/assets/data/tool-ui/pear_translation.dat new file mode 100644 index 0000000..95e80b6 Binary files /dev/null and b/assets/data/tool-ui/pear_translation.dat differ diff --git a/assets/data/tool-ui/pear_website_info.dat b/assets/data/tool-ui/pear_website_info.dat new file mode 100644 index 0000000..a06411c Binary files /dev/null and b/assets/data/tool-ui/pear_website_info.dat differ diff --git a/assets/data/tool-ui/pear_world_60.dat b/assets/data/tool-ui/pear_world_60.dat new file mode 100644 index 0000000..617feb3 Binary files /dev/null and b/assets/data/tool-ui/pear_world_60.dat differ diff --git a/assets/data/tool-ui/pear_world_population.dat b/assets/data/tool-ui/pear_world_population.dat new file mode 100644 index 0000000..75c0cf1 Binary files /dev/null and b/assets/data/tool-ui/pear_world_population.dat differ diff --git a/assets/data/tool-ui/pear_xf_spark.dat b/assets/data/tool-ui/pear_xf_spark.dat new file mode 100644 index 0000000..477aaa2 Binary files /dev/null and b/assets/data/tool-ui/pear_xf_spark.dat differ diff --git a/assets/data/tool-ui/percentage_calculator.dat b/assets/data/tool-ui/percentage_calculator.dat index 7388256..fb0d6ac 100644 Binary files a/assets/data/tool-ui/percentage_calculator.dat and b/assets/data/tool-ui/percentage_calculator.dat differ diff --git a/assets/data/tool-ui/percentage_change_calculator.dat b/assets/data/tool-ui/percentage_change_calculator.dat index 76d5f5d..80b7c00 100644 Binary files a/assets/data/tool-ui/percentage_change_calculator.dat and b/assets/data/tool-ui/percentage_change_calculator.dat differ diff --git a/assets/data/tool-ui/permutation_calculator.dat b/assets/data/tool-ui/permutation_calculator.dat index 5179bfd..e4a0d8e 100644 Binary files a/assets/data/tool-ui/permutation_calculator.dat and b/assets/data/tool-ui/permutation_calculator.dat differ diff --git a/assets/data/tool-ui/plugin-host.dat b/assets/data/tool-ui/plugin-host.dat index d64ca32..044c61b 100644 Binary files a/assets/data/tool-ui/plugin-host.dat and b/assets/data/tool-ui/plugin-host.dat differ diff --git a/assets/data/tool-ui/port_lookup.dat b/assets/data/tool-ui/port_lookup.dat index 89378ba..52714f4 100644 Binary files a/assets/data/tool-ui/port_lookup.dat and b/assets/data/tool-ui/port_lookup.dat differ diff --git a/assets/data/tool-ui/prefix_suffix_batch.dat b/assets/data/tool-ui/prefix_suffix_batch.dat index ea8cff1..278a023 100644 Binary files a/assets/data/tool-ui/prefix_suffix_batch.dat and b/assets/data/tool-ui/prefix_suffix_batch.dat differ diff --git a/assets/data/tool-ui/profanity_check.dat b/assets/data/tool-ui/profanity_check.dat index 535270a..bc89ee5 100644 Binary files a/assets/data/tool-ui/profanity_check.dat and b/assets/data/tool-ui/profanity_check.dat differ diff --git a/assets/data/tool-ui/punycode_codec.dat b/assets/data/tool-ui/punycode_codec.dat index b6799b5..a4fb8ff 100644 Binary files a/assets/data/tool-ui/punycode_codec.dat and b/assets/data/tool-ui/punycode_codec.dat differ diff --git a/assets/data/tool-ui/qq_avatar.dat b/assets/data/tool-ui/qq_avatar.dat index 5f14233..c043d50 100644 Binary files a/assets/data/tool-ui/qq_avatar.dat and b/assets/data/tool-ui/qq_avatar.dat differ diff --git a/assets/data/tool-ui/qr_generator.dat b/assets/data/tool-ui/qr_generator.dat index a2785c5..2b6b41c 100644 Binary files a/assets/data/tool-ui/qr_generator.dat and b/assets/data/tool-ui/qr_generator.dat differ diff --git a/assets/data/tool-ui/qrcode_scanner.dat b/assets/data/tool-ui/qrcode_scanner.dat index eef28e6..3af57fb 100644 Binary files a/assets/data/tool-ui/qrcode_scanner.dat and b/assets/data/tool-ui/qrcode_scanner.dat differ diff --git a/assets/data/tool-ui/query_builder.dat b/assets/data/tool-ui/query_builder.dat index 65e45a5..5f8fa1b 100644 Binary files a/assets/data/tool-ui/query_builder.dat and b/assets/data/tool-ui/query_builder.dat differ diff --git a/assets/data/tool-ui/random_cinema.dat b/assets/data/tool-ui/random_cinema.dat index 84a8535..3f7e677 100644 Binary files a/assets/data/tool-ui/random_cinema.dat and b/assets/data/tool-ui/random_cinema.dat differ diff --git a/assets/data/tool-ui/random_generator.dat b/assets/data/tool-ui/random_generator.dat index 76e0edb..a713ecf 100644 Binary files a/assets/data/tool-ui/random_generator.dat and b/assets/data/tool-ui/random_generator.dat differ diff --git a/assets/data/tool-ui/ratio_simplifier.dat b/assets/data/tool-ui/ratio_simplifier.dat index f155a46..9f6db45 100644 Binary files a/assets/data/tool-ui/ratio_simplifier.dat and b/assets/data/tool-ui/ratio_simplifier.dat differ diff --git a/assets/data/tool-ui/rdap_domain_lookup.dat b/assets/data/tool-ui/rdap_domain_lookup.dat index 0dbcab7..c19b46d 100644 Binary files a/assets/data/tool-ui/rdap_domain_lookup.dat and b/assets/data/tool-ui/rdap_domain_lookup.dat differ diff --git a/assets/data/tool-ui/rdap_ip_lookup.dat b/assets/data/tool-ui/rdap_ip_lookup.dat index e27c6b5..003cee7 100644 Binary files a/assets/data/tool-ui/rdap_ip_lookup.dat and b/assets/data/tool-ui/rdap_ip_lookup.dat differ diff --git a/assets/data/tool-ui/regex_preset_lookup.dat b/assets/data/tool-ui/regex_preset_lookup.dat index c87e5a2..c90aa5f 100644 Binary files a/assets/data/tool-ui/regex_preset_lookup.dat and b/assets/data/tool-ui/regex_preset_lookup.dat differ diff --git a/assets/data/tool-ui/regex_tool.dat b/assets/data/tool-ui/regex_tool.dat index 46db02d..b75cac2 100644 Binary files a/assets/data/tool-ui/regex_tool.dat and b/assets/data/tool-ui/regex_tool.dat differ diff --git a/assets/data/tool-ui/safe_browser.dat b/assets/data/tool-ui/safe_browser.dat index 0955443..2644184 100644 Binary files a/assets/data/tool-ui/safe_browser.dat and b/assets/data/tool-ui/safe_browser.dat differ diff --git a/assets/data/tool-ui/sanguosha_skin.dat b/assets/data/tool-ui/sanguosha_skin.dat index 0277eab..cbc9630 100644 Binary files a/assets/data/tool-ui/sanguosha_skin.dat and b/assets/data/tool-ui/sanguosha_skin.dat differ diff --git a/assets/data/tool-ui/serial_terminal.dat b/assets/data/tool-ui/serial_terminal.dat index aadc81c..b98299f 100644 Binary files a/assets/data/tool-ui/serial_terminal.dat and b/assets/data/tool-ui/serial_terminal.dat differ diff --git a/assets/data/tool-ui/shelf_life.dat b/assets/data/tool-ui/shelf_life.dat index 2e26bba..852ce42 100644 Binary files a/assets/data/tool-ui/shelf_life.dat and b/assets/data/tool-ui/shelf_life.dat differ diff --git a/assets/data/tool-ui/simple_interest_calculator.dat b/assets/data/tool-ui/simple_interest_calculator.dat index 0f6dec6..1b04023 100644 Binary files a/assets/data/tool-ui/simple_interest_calculator.dat and b/assets/data/tool-ui/simple_interest_calculator.dat differ diff --git a/assets/data/tool-ui/slug_generator.dat b/assets/data/tool-ui/slug_generator.dat index 140d4dd..80cc52c 100644 Binary files a/assets/data/tool-ui/slug_generator.dat and b/assets/data/tool-ui/slug_generator.dat differ diff --git a/assets/data/tool-ui/smart_search.dat b/assets/data/tool-ui/smart_search.dat index 7c5cf34..561dd60 100644 Binary files a/assets/data/tool-ui/smart_search.dat and b/assets/data/tool-ui/smart_search.dat differ diff --git a/assets/data/tool-ui/speed_converter.dat b/assets/data/tool-ui/speed_converter.dat index ce546c0..a253d3c 100644 Binary files a/assets/data/tool-ui/speed_converter.dat and b/assets/data/tool-ui/speed_converter.dat differ diff --git a/assets/data/tool-ui/sql_formatter.dat b/assets/data/tool-ui/sql_formatter.dat index f63f28f..4b5e8ce 100644 Binary files a/assets/data/tool-ui/sql_formatter.dat and b/assets/data/tool-ui/sql_formatter.dat differ diff --git a/assets/data/tool-ui/system_info.dat b/assets/data/tool-ui/system_info.dat index be7284b..a4e4d22 100644 Binary files a/assets/data/tool-ui/system_info.dat and b/assets/data/tool-ui/system_info.dat differ diff --git a/assets/data/tool-ui/system_tool.dat b/assets/data/tool-ui/system_tool.dat index 71ecba0..4b94f49 100644 Binary files a/assets/data/tool-ui/system_tool.dat and b/assets/data/tool-ui/system_tool.dat differ diff --git a/assets/data/tool-ui/tax_calculator.dat b/assets/data/tool-ui/tax_calculator.dat index c0e59ca..ffb54dc 100644 Binary files a/assets/data/tool-ui/tax_calculator.dat and b/assets/data/tool-ui/tax_calculator.dat differ diff --git a/assets/data/tool-ui/tech_news.dat b/assets/data/tool-ui/tech_news.dat index fd1a734..d92310d 100644 Binary files a/assets/data/tool-ui/tech_news.dat and b/assets/data/tool-ui/tech_news.dat differ diff --git a/assets/data/tool-ui/temperature_converter.dat b/assets/data/tool-ui/temperature_converter.dat index 6ef79b2..3938097 100644 Binary files a/assets/data/tool-ui/temperature_converter.dat and b/assets/data/tool-ui/temperature_converter.dat differ diff --git a/assets/data/tool-ui/template_filler.dat b/assets/data/tool-ui/template_filler.dat index 305ae22..c5faef7 100644 Binary files a/assets/data/tool-ui/template_filler.dat and b/assets/data/tool-ui/template_filler.dat differ diff --git a/assets/data/tool-ui/text_deduplicator.dat b/assets/data/tool-ui/text_deduplicator.dat index ddbe3c7..976b7a8 100644 Binary files a/assets/data/tool-ui/text_deduplicator.dat and b/assets/data/tool-ui/text_deduplicator.dat differ diff --git a/assets/data/tool-ui/text_diff.dat b/assets/data/tool-ui/text_diff.dat index 6b74f0c..739a037 100644 Binary files a/assets/data/tool-ui/text_diff.dat and b/assets/data/tool-ui/text_diff.dat differ diff --git a/assets/data/tool-ui/text_filter.dat b/assets/data/tool-ui/text_filter.dat index 8d0af4e..5b922ea 100644 Binary files a/assets/data/tool-ui/text_filter.dat and b/assets/data/tool-ui/text_filter.dat differ diff --git a/assets/data/tool-ui/text_merger.dat b/assets/data/tool-ui/text_merger.dat index 433a4aa..aa2fbfa 100644 Binary files a/assets/data/tool-ui/text_merger.dat and b/assets/data/tool-ui/text_merger.dat differ diff --git a/assets/data/tool-ui/text_numbering.dat b/assets/data/tool-ui/text_numbering.dat index c793ea7..d4acd5c 100644 Binary files a/assets/data/tool-ui/text_numbering.dat and b/assets/data/tool-ui/text_numbering.dat differ diff --git a/assets/data/tool-ui/text_reverser.dat b/assets/data/tool-ui/text_reverser.dat index 5f686ae..2dd020b 100644 Binary files a/assets/data/tool-ui/text_reverser.dat and b/assets/data/tool-ui/text_reverser.dat differ diff --git a/assets/data/tool-ui/text_sorter.dat b/assets/data/tool-ui/text_sorter.dat index 4b3b267..af1980e 100644 Binary files a/assets/data/tool-ui/text_sorter.dat and b/assets/data/tool-ui/text_sorter.dat differ diff --git a/assets/data/tool-ui/text_splitter.dat b/assets/data/tool-ui/text_splitter.dat index 508f4ca..1bae7bb 100644 Binary files a/assets/data/tool-ui/text_splitter.dat and b/assets/data/tool-ui/text_splitter.dat differ diff --git a/assets/data/tool-ui/text_statistics.dat b/assets/data/tool-ui/text_statistics.dat index cff05a1..c59f973 100644 Binary files a/assets/data/tool-ui/text_statistics.dat and b/assets/data/tool-ui/text_statistics.dat differ diff --git a/assets/data/tool-ui/text_trimmer.dat b/assets/data/tool-ui/text_trimmer.dat index 4786bff..107731e 100644 Binary files a/assets/data/tool-ui/text_trimmer.dat and b/assets/data/tool-ui/text_trimmer.dat differ diff --git a/assets/data/tool-ui/time_duration_converter.dat b/assets/data/tool-ui/time_duration_converter.dat index 340a86d..1440123 100644 Binary files a/assets/data/tool-ui/time_duration_converter.dat and b/assets/data/tool-ui/time_duration_converter.dat differ diff --git a/assets/data/tool-ui/timestamp_converter.dat b/assets/data/tool-ui/timestamp_converter.dat index 4e20d65..bb46715 100644 Binary files a/assets/data/tool-ui/timestamp_converter.dat and b/assets/data/tool-ui/timestamp_converter.dat differ diff --git a/assets/data/tool-ui/timezone_abbr_lookup.dat b/assets/data/tool-ui/timezone_abbr_lookup.dat index 4ad984f..08cefee 100644 Binary files a/assets/data/tool-ui/timezone_abbr_lookup.dat and b/assets/data/tool-ui/timezone_abbr_lookup.dat differ diff --git a/assets/data/tool-ui/totp_generator.dat b/assets/data/tool-ui/totp_generator.dat index 16da476..35b120c 100644 Binary files a/assets/data/tool-ui/totp_generator.dat and b/assets/data/tool-ui/totp_generator.dat differ diff --git a/assets/data/tool-ui/train_query.dat b/assets/data/tool-ui/train_query.dat index 99b5b6b..cb41438 100644 Binary files a/assets/data/tool-ui/train_query.dat and b/assets/data/tool-ui/train_query.dat differ diff --git a/assets/data/tool-ui/ulid_generator.dat b/assets/data/tool-ui/ulid_generator.dat index 67f4216..d7e1d92 100644 Binary files a/assets/data/tool-ui/ulid_generator.dat and b/assets/data/tool-ui/ulid_generator.dat differ diff --git a/assets/data/tool-ui/unicode_block_lookup.dat b/assets/data/tool-ui/unicode_block_lookup.dat index b6f6389..8d6cf33 100644 Binary files a/assets/data/tool-ui/unicode_block_lookup.dat and b/assets/data/tool-ui/unicode_block_lookup.dat differ diff --git a/assets/data/tool-ui/unicode_codec.dat b/assets/data/tool-ui/unicode_codec.dat index 6946e1f..eafaf62 100644 Binary files a/assets/data/tool-ui/unicode_codec.dat and b/assets/data/tool-ui/unicode_codec.dat differ diff --git a/assets/data/tool-ui/unit_converter.dat b/assets/data/tool-ui/unit_converter.dat index 87164f0..23a4fb2 100644 Binary files a/assets/data/tool-ui/unit_converter.dat and b/assets/data/tool-ui/unit_converter.dat differ diff --git a/assets/data/tool-ui/url_codec.dat b/assets/data/tool-ui/url_codec.dat index a0ca94d..609d2bd 100644 Binary files a/assets/data/tool-ui/url_codec.dat and b/assets/data/tool-ui/url_codec.dat differ diff --git a/assets/data/tool-ui/url_inspector.dat b/assets/data/tool-ui/url_inspector.dat index e00b2e3..16efe9a 100644 Binary files a/assets/data/tool-ui/url_inspector.dat and b/assets/data/tool-ui/url_inspector.dat differ diff --git a/assets/data/tool-ui/url_redirect_trace.dat b/assets/data/tool-ui/url_redirect_trace.dat index 5fa5063..930f081 100644 Binary files a/assets/data/tool-ui/url_redirect_trace.dat and b/assets/data/tool-ui/url_redirect_trace.dat differ diff --git a/assets/data/tool-ui/uuid_generator.dat b/assets/data/tool-ui/uuid_generator.dat index 93a9c46..a34c6c3 100644 Binary files a/assets/data/tool-ui/uuid_generator.dat and b/assets/data/tool-ui/uuid_generator.dat differ diff --git a/assets/data/tool-ui/volume_converter.dat b/assets/data/tool-ui/volume_converter.dat index b71e2d8..aab3254 100644 Binary files a/assets/data/tool-ui/volume_converter.dat and b/assets/data/tool-ui/volume_converter.dat differ diff --git a/assets/data/tool-ui/weather.dat b/assets/data/tool-ui/weather.dat index 0d51680..dfe87e4 100644 Binary files a/assets/data/tool-ui/weather.dat and b/assets/data/tool-ui/weather.dat differ diff --git a/assets/data/tool-ui/weight_converter.dat b/assets/data/tool-ui/weight_converter.dat index 06939bf..de74bfd 100644 Binary files a/assets/data/tool-ui/weight_converter.dat and b/assets/data/tool-ui/weight_converter.dat differ diff --git a/assets/data/tool-ui/whitespace_normalizer.dat b/assets/data/tool-ui/whitespace_normalizer.dat index f722155..b392af8 100644 Binary files a/assets/data/tool-ui/whitespace_normalizer.dat and b/assets/data/tool-ui/whitespace_normalizer.dat differ diff --git a/assets/data/tool-ui/workday_calculator.dat b/assets/data/tool-ui/workday_calculator.dat index c85f4f3..78f64c7 100644 Binary files a/assets/data/tool-ui/workday_calculator.dat and b/assets/data/tool-ui/workday_calculator.dat differ diff --git a/assets/data/tool-ui/wx_domain_check.dat b/assets/data/tool-ui/wx_domain_check.dat index 6770201..05018d2 100644 Binary files a/assets/data/tool-ui/wx_domain_check.dat and b/assets/data/tool-ui/wx_domain_check.dat differ diff --git a/assets/data/tool-ui/xml_formatter.dat b/assets/data/tool-ui/xml_formatter.dat index 91d0e09..820788c 100644 Binary files a/assets/data/tool-ui/xml_formatter.dat and b/assets/data/tool-ui/xml_formatter.dat differ diff --git a/assets/data/tool-ui/yaml_json_converter.dat b/assets/data/tool-ui/yaml_json_converter.dat index f647fff..047f073 100644 Binary files a/assets/data/tool-ui/yaml_json_converter.dat and b/assets/data/tool-ui/yaml_json_converter.dat differ diff --git a/assets/data/tool-ui/ymhut_uutool_suite.dat b/assets/data/tool-ui/ymhut_uutool_suite.dat index 9f999d4..91fcff8 100644 Binary files a/assets/data/tool-ui/ymhut_uutool_suite.dat and b/assets/data/tool-ui/ymhut_uutool_suite.dat differ diff --git a/assets/data/tool-ui/zhihu_hot.dat b/assets/data/tool-ui/zhihu_hot.dat index c57ad9f..f40cc07 100644 Binary files a/assets/data/tool-ui/zhihu_hot.dat and b/assets/data/tool-ui/zhihu_hot.dat differ diff --git a/assets/icon.ico b/assets/icon.ico index b47eb0f..ab342b9 100644 Binary files a/assets/icon.ico and b/assets/icon.ico differ diff --git a/assets/icons/app_icon.ico b/assets/icons/app_icon.ico index 08918ba..ab342b9 100644 Binary files a/assets/icons/app_icon.ico and b/assets/icons/app_icon.ico differ diff --git a/assets/icons/app_icon.png b/assets/icons/app_icon.png index 55ce8df..96bda83 100644 Binary files a/assets/icons/app_icon.png and b/assets/icons/app_icon.png differ diff --git a/assets/icons/icon.png b/assets/icons/icon.png index 55ce8df..96bda83 100644 Binary files a/assets/icons/icon.png and b/assets/icons/icon.png differ diff --git a/installer/ymhut_box_winui.iss b/installer/ymhut_box_winui.iss index f1b3590..c5a0975 100644 --- a/installer/ymhut_box_winui.iss +++ b/installer/ymhut_box_winui.iss @@ -82,6 +82,7 @@ CloseApplicationsFilter={#MyAppExeName} #endif RestartApplications=no UninstallDisplayIcon={app}\{#MyAppExeName} +UninstallFilesDir={app}\Runtime\uninstall-engine [Languages] Name: "chinesesimp"; MessagesFile: "{#ChineseMessagesFile}" @@ -162,6 +163,7 @@ Name: "desktopicon"; Description: "{cm:DesktopIcon}"; GroupDescription: "{cm:Sho Name: "startmenuicon"; Description: "{cm:StartMenuIcon}"; GroupDescription: "{cm:ShortcutGroup}"; Flags: unchecked Name: "autostart"; Description: "{cm:AutoStart}"; GroupDescription: "{cm:StartupGroup}"; Flags: unchecked Name: "launchapp"; Description: "{cm:LaunchApp}"; GroupDescription: "{cm:StartupGroup}"; Flags: checkedonce +Name: "cleancache"; Description: "{cm:CleanCache}"; GroupDescription: "{cm:MaintenanceGroup}"; Flags: unchecked [Files] Source: "{#PayloadDir}\*"; DestDir: "{app}"; Excludes: "*.pri"; Flags: ignoreversion recursesubdirs createallsubdirs; BeforeInstall: LogCurrentExtractFile @@ -178,50 +180,6 @@ Name: "{app}\data" Name: "{app}\FeedbackPackages"; Permissions: users-modify Name: "{app}\data\legacy-imports" -[InstallDelete] -Type: filesandordirs; Name: "{app}\resources\lang" -Type: filesandordirs; Name: "{app}\lang" -#if Int(QAIsolated) == 0 -Type: filesandordirs; Name: "{localappdata}\YMhut Box\WinUI\Runtime" -Type: filesandordirs; Name: "{localappdata}\YMhut Box\WinUI\runtime" -Type: filesandordirs; Name: "{localappdata}\YMhut Box\WinUI\Runtimes" -Type: filesandordirs; Name: "{localappdata}\YMhut Box\WinUI\runtimes" -Type: filesandordirs; Name: "{localappdata}\YMhut Box\WinUI\Tools" -Type: filesandordirs; Name: "{localappdata}\YMhut Box\WinUI\Metadata" -Type: filesandordirs; Name: "{localappdata}\YMhut Box\Runtime" -Type: filesandordirs; Name: "{localappdata}\YMhut Box\runtime" -Type: filesandordirs; Name: "{localappdata}\YMhut Box\Tools" -Type: filesandordirs; Name: "{localappdata}\YMhut Box\Metadata" -Type: filesandordirs; Name: "{localappdata}\ymhut_box\Runtime" -Type: filesandordirs; Name: "{localappdata}\ymhut_box\runtime" -Type: filesandordirs; Name: "{localappdata}\ymhut_box\Tools" -Type: filesandordirs; Name: "{localappdata}\ymhut_box\Metadata" -Type: filesandordirs; Name: "{localappdata}\YMhut Box\cache"; Check: ShouldCleanCache -Type: filesandordirs; Name: "{userappdata}\YMhut Box\cache"; Check: ShouldCleanCache -Type: filesandordirs; Name: "{localappdata}\ymhut_box\cache"; Check: ShouldCleanCache -Type: filesandordirs; Name: "{userappdata}\ymhut_box\cache"; Check: ShouldCleanCache -#endif -Type: files; Name: "{app}\*.winmd" -Type: files; Name: "{app}\*.pdb" -Type: files; Name: "{app}\workloads*.json" -Type: files; Name: "{app}\installer-layout.txt" -Type: files; Name: "{app}\config\installer-layout.dat" -Type: files; Name: "{app}\config\installer-required-files.dat" -Type: files; Name: "{app}\Assets\data\reference-data.dat" -Type: files; Name: "{app}\YMhut.Box.Worker.exe" -Type: files; Name: "{app}\YMhut.Box.Worker.dll" -Type: files; Name: "{app}\YMhut.Box.Worker.deps.json" -Type: files; Name: "{app}\YMhut.Box.Worker.runtimeconfig.json" -Type: files; Name: "{app}\YMhut.Box.PluginHost.exe" -Type: files; Name: "{app}\YMhut.Box.PluginHost.dll" -Type: files; Name: "{app}\YMhut.Box.PluginHost.deps.json" -Type: files; Name: "{app}\YMhut.Box.PluginHost.runtimeconfig.json" -Type: filesandordirs; Name: "{app}\updater" -Type: files; Name: "{app}\YMhut.Box.Updater.exe" -Type: files; Name: "{app}\YMhut.Box.Updater.dll" -Type: files; Name: "{app}\YMhut.Box.Updater.deps.json" -Type: files; Name: "{app}\YMhut.Box.Updater.runtimeconfig.json" - [Icons] Name: "{userprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: startmenuicon Name: "{userdesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon @@ -265,6 +223,9 @@ var InstallOutputPendingLineCount: Integer; InstallOutputBuffer: string; LastExtractFileName: string; + OldPayloadFiles: TStringList; + NewPayloadFiles: TStringList; + LegacyBackupDir: string; function SendMessage(hWnd: Longint; Msg: Longint; wParam: Longint; lParam: Longint): Longint; external 'SendMessageW@user32.dll stdcall'; @@ -326,7 +287,7 @@ end; function ShouldCleanCache(): Boolean; begin - Result := True; + Result := WizardIsTaskSelected('cleancache'); end; function DetectExistingInstallDir(): string; @@ -373,9 +334,13 @@ end; procedure LayoutInstallOutputMemo(); forward; procedure ScrollInstallOutputToBottom(); forward; +procedure CaptureOldPayloadManifest(); forward; +procedure FinalizeLegacyInstallLayout(); forward; procedure InitializeWizard; begin + OldPayloadFiles := TStringList.Create; + NewPayloadFiles := TStringList.Create; DownloadPage := CreateDownloadPage(SetupMessage(msgWizardPreparing), SetupMessage(msgPreparingDesc), nil); DownloadPage.ShowBaseNameInsteadOfUrl := True; ExistingInstallDir := DetectExistingInstallDir(); @@ -902,37 +867,109 @@ begin end; end; -function ShouldPreserveAppEntry(const Name: string): Boolean; -var - LowerName: string; +function NormalizeManifestEntry(const Value: string): string; begin - LowerName := Lowercase(Name); - Result := - (LowerName = 'data') or - (LowerName = 'feedbackpackages') or - (Pos('unins', LowerName) = 1); + Result := Trim(Value); + StringChangeEx(Result, '/', '\', True); + while (Length(Result) > 0) and (Result[1] = '\') do + Delete(Result, 1, 1); + Result := Lowercase(Result); + if (Result = '') or (Result = '..') or + (Pos('..\', Result) > 0) or (Pos('\..', Result) > 0) or + (Pos(':', Result) > 0) then + Result := ''; end; -procedure CleanOldApplicationPayload(const DirName: string); +procedure LoadInstallManifestFiles(const FileName: string; Files: TStringList); var - FindRec: TFindRec; - EntryPath: string; + Lines: TStringList; + I: Integer; + Entry: string; + InFiles: Boolean; begin - if not DirExists(DirName) then + Files.Clear; + if not FileExists(FileName) then Exit; - if FindFirst(AddBackslash(DirName) + '*', FindRec) then + Lines := TStringList.Create; + try + Lines.LoadFromFile(FileName); + InFiles := False; + for I := 0 to Lines.Count - 1 do + begin + Entry := Trim(Lines[I]); + if (Length(Entry) > 1) and (Entry[1] = '[') then + begin + InFiles := CompareText(Entry, '[Files]') = 0; + Continue; + end; + + if InFiles then + begin + Entry := NormalizeManifestEntry(Entry); + if (Entry <> '') and (Files.IndexOf(Entry) < 0) then + Files.Add(Entry); + end; + end; + finally + Lines.Free; + end; +end; + +function ManifestContains(Files: TStringList; const RelativePath: string): Boolean; +var + Entry: string; +begin + Entry := NormalizeManifestEntry(RelativePath); + Result := (Entry <> '') and (Files.IndexOf(Entry) >= 0); +end; + +function IsPreservedInstallPath(const RelativePath: string): Boolean; +var + Entry: string; +begin + Entry := NormalizeManifestEntry(RelativePath); + Result := + (Entry = 'data') or (Pos('data\', Entry) = 1) or + (Entry = 'feedbackpackages') or (Pos('feedbackpackages\', Entry) = 1) or + (Entry = 'config\install-manifest.ini') or + (Entry = 'unins000.exe') or + (Entry = 'runtime\uninstall-engine') or + (Pos('runtime\uninstall-engine\', Entry) = 1); +end; + +function RelativeInstallPath(const AppDir, FullPath: string): string; +begin + Result := ''; + if Pos(Lowercase(AddBackslash(AppDir)), Lowercase(FullPath)) <> 1 then + Exit; + Result := NormalizeManifestEntry(Copy(FullPath, Length(AddBackslash(AppDir)) + 1, Length(FullPath))); +end; + +procedure CaptureOldPayloadManifest(); +var + ManifestPath: string; +begin + if OldPayloadFiles = nil then + Exit; + ManifestPath := AddBackslash(WizardDirValue) + 'config\install-manifest.ini'; + LoadInstallManifestFiles(ManifestPath, OldPayloadFiles); + Log(Format('Captured %d files from the previous install manifest.', [OldPayloadFiles.Count])); +end; + +function HasToolUiPayload(const AppDir: string): Boolean; +var + FindRec: TFindRec; +begin + Result := False; + if FindFirst(AddBackslash(AppDir) + 'Assets\data\tool-ui\*.dat', FindRec) then begin try repeat - if (FindRec.Name <> '.') and (FindRec.Name <> '..') and - (not ShouldPreserveAppEntry(FindRec.Name)) then + if (FindRec.Attributes and DirectoryAttribute) = 0 then begin - EntryPath := AddBackslash(DirName) + FindRec.Name; - if ((FindRec.Attributes and DirectoryAttribute) <> 0) then - DelTree(EntryPath, True, True, True) - else - DeleteFile(EntryPath); + Result := True; + Exit; end; until not FindNext(FindRec); finally @@ -941,21 +978,169 @@ begin end; end; -procedure CleanLegacyInstallLayout(); +procedure VerifyNewApplicationPayload(const AppDir: string); +var + ManifestPath: string; +begin + ManifestPath := AddBackslash(AppDir) + 'config\install-manifest.ini'; + LoadInstallManifestFiles(ManifestPath, NewPayloadFiles); + if NewPayloadFiles.Count = 0 then + RaiseException('The new install manifest is missing or empty. Legacy files were not changed.'); + + if not FileExists(AddBackslash(AppDir) + '{#MyAppExeName}') or + not FileExists(AddBackslash(AppDir) + 'YMhutBox.dll') or + not FileExists(AddBackslash(AppDir) + 'Assets\runtime-assets.dat') or + not FileExists(AddBackslash(AppDir) + 'Assets\data\ymhut-data.ybin') or + not FileExists(AddBackslash(AppDir) + 'music-api\node.exe') or + not FileExists(AddBackslash(AppDir) + 'music-api\api\ymhut-sidecar.js') or + not FileExists(AddBackslash(AppDir) + 'music-api\api\pnpm-lock.yaml') or + not FileExists(AddBackslash(AppDir) + 'music-api\api\LICENSE') or + not FileExists(AddBackslash(AppDir) + 'unins000.exe') or + not FileExists(AddBackslash(AppDir) + 'Runtime\uninstall-engine\unins000.exe') or + not FileExists(AddBackslash(AppDir) + 'Runtime\uninstall-engine\unins000.dat') or + not HasToolUiPayload(AppDir) then + RaiseException('The new application payload is incomplete. Legacy files were not changed.'); +end; + +procedure RegisterCustomUninstallEntry(const AppDir: string); +var + UninstallKey: string; + UninstallCommand: string; + QuietUninstallCommand: string; +begin + UninstallKey := 'Software\Microsoft\Windows\CurrentVersion\Uninstall\' + '{#MyAppId}' + '_is1'; + UninstallCommand := '"' + AddBackslash(AppDir) + 'unins000.exe" /UNINSTALL /INSTALLDIR="' + AppDir + '"'; + QuietUninstallCommand := UninstallCommand + ' /VERYSILENT /NORESTART'; + + if IsAdminInstallMode then + begin + RegWriteStringValue(HKLM64, UninstallKey, 'UninstallString', UninstallCommand); + RegWriteStringValue(HKLM64, UninstallKey, 'QuietUninstallString', QuietUninstallCommand); + end + else + begin + RegWriteStringValue(HKCU, UninstallKey, 'UninstallString', UninstallCommand); + RegWriteStringValue(HKCU, UninstallKey, 'QuietUninstallString', QuietUninstallCommand); + end; + Log('Registered the YMhut Box uninstall bootstrap.'); +end; + +procedure DeleteKnownObsoletePayload(const AppDir: string); +var + I: Integer; + RelativePath: string; + FullPath: string; +begin + for I := 0 to OldPayloadFiles.Count - 1 do + begin + RelativePath := OldPayloadFiles[I]; + if (not ManifestContains(NewPayloadFiles, RelativePath)) and + (not IsPreservedInstallPath(RelativePath)) then + begin + FullPath := AddBackslash(AppDir) + RelativePath; + if FileExists(FullPath) then + begin + if DeleteFile(FullPath) then + Log('Removed obsolete payload file: ' + RelativePath) + else + Log('Unable to remove obsolete payload file: ' + RelativePath); + end; + end; + end; +end; + +procedure QuarantineLegacyFile(const AppDir, FullPath, RelativePath: string); +var + TargetPath: string; +begin + if LegacyBackupDir = '' then + begin + LegacyBackupDir := AddBackslash(AppDir) + 'data\legacy-imports\program-backup-' + + GetDateTimeString('yyyymmdd-hhnnss', '', ''); + end; + + TargetPath := AddBackslash(LegacyBackupDir) + RelativePath; + ForceDirectories(ExtractFileDir(TargetPath)); + if FileExists(TargetPath) then + TargetPath := TargetPath + '.old'; + + if RenameFile(FullPath, TargetPath) or + (CopyFile(FullPath, TargetPath, False) and DeleteFile(FullPath)) then + Log('Preserved unknown legacy file: ' + RelativePath) + else + Log('Unable to preserve unknown legacy file: ' + RelativePath); +end; + +procedure QuarantineUnknownLegacyFiles(const AppDir, CurrentDir: string); +var + FindRec: TFindRec; + FullPath: string; + RelativePath: string; +begin + if not DirExists(CurrentDir) then + Exit; + + if FindFirst(AddBackslash(CurrentDir) + '*', FindRec) then + begin + try + repeat + if (FindRec.Name <> '.') and (FindRec.Name <> '..') then + begin + FullPath := AddBackslash(CurrentDir) + FindRec.Name; + RelativePath := RelativeInstallPath(AppDir, FullPath); + if (RelativePath <> '') and (not IsPreservedInstallPath(RelativePath)) then + begin + if (FindRec.Attributes and DirectoryAttribute) <> 0 then + begin + QuarantineUnknownLegacyFiles(AppDir, FullPath); + RemoveDir(FullPath); + end + else if (not ManifestContains(NewPayloadFiles, RelativePath)) and + (not ManifestContains(OldPayloadFiles, RelativePath)) then + QuarantineLegacyFile(AppDir, FullPath, RelativePath); + end; + end; + until not FindNext(FindRec); + finally + FindClose(FindRec); + end; + end; +end; + +procedure CleanLegacyRuntimeAndCache(); +begin +#if Int(QAIsolated) == 0 + DelTree(ExpandConstant('{localappdata}\YMhut Box\WinUI\Runtime'), True, True, True); + DelTree(ExpandConstant('{localappdata}\YMhut Box\WinUI\Runtimes'), True, True, True); + DelTree(ExpandConstant('{localappdata}\YMhut Box\WinUI\Tools'), True, True, True); + DelTree(ExpandConstant('{localappdata}\YMhut Box\WinUI\Metadata'), True, True, True); + DelTree(ExpandConstant('{localappdata}\YMhut Box\Runtime'), True, True, True); + DelTree(ExpandConstant('{localappdata}\YMhut Box\Tools'), True, True, True); + DelTree(ExpandConstant('{localappdata}\YMhut Box\Metadata'), True, True, True); + DelTree(ExpandConstant('{localappdata}\ymhut_box\Runtime'), True, True, True); + DelTree(ExpandConstant('{localappdata}\ymhut_box\Tools'), True, True, True); + DelTree(ExpandConstant('{localappdata}\ymhut_box\Metadata'), True, True, True); + if ShouldCleanCache() then + begin + DelTree(ExpandConstant('{localappdata}\YMhut Box\cache'), True, True, True); + DelTree(ExpandConstant('{userappdata}\YMhut Box\cache'), True, True, True); + DelTree(ExpandConstant('{localappdata}\ymhut_box\cache'), True, True, True); + DelTree(ExpandConstant('{userappdata}\ymhut_box\cache'), True, True, True); + end; +#endif +end; + +procedure FinalizeLegacyInstallLayout(); var AppDir: string; begin AppDir := ExpandConstant('{app}'); - CleanOldApplicationPayload(AppDir); - DeleteFilesByPattern(AppDir, '*.winmd', True); - DeleteFilesByPattern(AppDir, '*.pdb', True); - DeleteFilesByPattern(AppDir, 'workloads*.json', True); - DeleteFilesByPattern(AddBackslash(AppDir) + 'Assets\data', '*.json', True); - DeleteFile(AddBackslash(AppDir) + 'installer-layout.txt'); - DeleteFile(AddBackslash(AppDir) + 'config\installer-layout.dat'); - DeleteFile(AddBackslash(AppDir) + 'config\installer-required-files.dat'); - DeleteFile(AddBackslash(AppDir) + 'Assets\data\reference-data.dat'); - CleanCultureFolders(AppDir); + VerifyNewApplicationPayload(AppDir); + if OldPayloadFiles.Count > 0 then + DeleteKnownObsoletePayload(AppDir) + else + QuarantineUnknownLegacyFiles(AppDir, AppDir); + CleanLegacyRuntimeAndCache(); end; function PrepareToInstall(var NeedsRestart: Boolean): string; @@ -978,6 +1163,8 @@ begin Exit; end; + CaptureOldPayloadManifest(); + try if IsWebView2Installed() then LogBootstrapEvent('DEPENDENCY', 'ready|' + GetCustomMessageValue('WebView2RuntimeName')) @@ -1028,8 +1215,6 @@ begin LastInstallProgressPercent := -1; LastExtractFileName := ''; LogBootstrapEvent('STAGE', 'files'); - AppendInstallOutput(CustomMessage('InstallOutputClean')); - CleanLegacyInstallLayout(); AppendInstallOutput(CustomMessage('InstallOutputExtract')); end else if CurStep = ssPostInstall then @@ -1037,6 +1222,8 @@ begin FlushInstallOutput(); LogBootstrapEvent('STAGE', 'finalize'); AppendInstallOutput(CustomMessage('InstallOutputPostInstall')); + FinalizeLegacyInstallLayout(); + RegisterCustomUninstallEntry(ExpandConstant('{app}')); #if Int(QAIsolated) == 0 MigrateLegacyData(); #endif @@ -1044,6 +1231,14 @@ begin end; end; +procedure DeinitializeSetup(); +begin + if OldPayloadFiles <> nil then + OldPayloadFiles.Free; + if NewPayloadFiles <> nil then + NewPayloadFiles.Free; +end; + procedure CurInstallProgressChanged(CurProgress, MaxProgress: Integer); var ProgressPercent: Integer; diff --git a/scripts/build-winui.ps1 b/scripts/build-winui.ps1 index ad28815..2716399 100644 --- a/scripts/build-winui.ps1 +++ b/scripts/build-winui.ps1 @@ -41,6 +41,7 @@ $ServerPublicRoot = Join-Path $Root 'server\update\public' $ServerDownloadRoot = Join-Path $ServerPublicRoot 'downloads' $ToolStateRoot = Join-Path $Root '.cache\tool_state' $NuGetRoot = Join-Path $Root '.cache\nuget' +$BuildTempRoot = Join-Path $Root '.cache\build-temp' $TauriHostRoot = Join-Path $Root 'src\YMhut.Box.PluginTauriHost\src-tauri' $RepositoryCargoHome = Join-Path $Root '.cache\rust-toolchain\cargo' $RepositoryRustupHome = Join-Path $Root '.cache\rust-toolchain\rustup' @@ -324,7 +325,10 @@ function Ensure-TauriPluginHost { return $executable } - Write-Warning 'Cargo was not found, so the optional Tauri plugin host will not be included. Install the Rust stable MSVC toolchain to build ExternalRuntime support.' + if ($Configuration -ieq 'Release') { + throw 'Cargo was not found and no previously built Release Tauri host is available. Release packages require the bundled plugin host.' + } + Write-Warning 'Cargo was not found, so the optional Debug Tauri plugin host will not be included.' return $null } @@ -516,6 +520,12 @@ function Save-ScaledLogo { } function Ensure-MsixAssets { + $iconScript = Join-Path $Root 'scripts\generate-app-icons.ps1' + & $iconScript -Root $Root + if (-not $?) { + throw 'Application icon generation failed.' + } + $sourceIcon = Join-Path $Root 'assets\icons\app_icon.png' if (-not (Test-Path -LiteralPath $sourceIcon)) { throw "MSIX source icon was not found: $sourceIcon" @@ -527,6 +537,18 @@ function Ensure-MsixAssets { Save-ScaledLogo $sourceIcon (Join-Path $ProjectAssets 'Square150x150Logo.png') 150 150 16 '#00FFFFFF' Save-ScaledLogo $sourceIcon (Join-Path $ProjectAssets 'LockScreenLogo.png') 70 70 8 '#00FFFFFF' Save-ScaledLogo $sourceIcon (Join-Path $ProjectAssets 'Wide310x150Logo.png') 310 150 24 '#F5F6F7' -Wordmark + + $iconPath = Join-Path $ProjectAssets 'app_icon.ico' + $bytes = [IO.File]::ReadAllBytes($iconPath) + $count = [BitConverter]::ToUInt16($bytes, 4) + $sizes = for ($index = 0; $index -lt $count; $index++) { + $value = $bytes[6 + ($index * 16)] + if ($value -eq 0) { 256 } else { [int] $value } + } + $missing = @(16, 20, 24, 32, 40, 48, 64, 96, 128, 256) | Where-Object { $_ -notin $sizes } + if ($missing.Count -gt 0) { + throw "Application icon is missing DPI frames: $($missing -join ', ')" + } } function Ensure-LocalDeveloperCertificate { @@ -712,7 +734,13 @@ function Write-LayoutManifests([string] $Directory) { $configDir = Join-Path $Directory 'config' New-Directory $configDir - $required = @($files | Where-Object { $_ -in @('YMhutBox.exe', 'YMhutBox.dll', 'WebView2Loader.dll') }) + $required = @($files | Where-Object { $_ -in @( + 'YMhutBox.exe', + 'YMhutBox.dll', + 'WebView2Loader.dll', + 'Assets/runtime-assets.dat', + 'Assets/data/ymhut-data.ybin' + ) }) if ($required.Count -eq 0) { $required = @('YMhutBox.exe') } @@ -720,6 +748,7 @@ function Write-LayoutManifests([string] $Directory) { $manifest = [System.Collections.Generic.List[string]]::new() if ($versionInfo) { $manifest.Add('[Release]') + $manifest.Add('ManifestVersion=2') $manifest.Add("Version=$($versionInfo.Version)") $manifest.Add("Build=$($versionInfo.Build)") $manifest.Add("Channel=$($versionInfo.Channel)") @@ -1097,6 +1126,7 @@ function Normalize-PublishLayout([string] $Directory) { Remove-RootHelperDuplicates $Directory Assert-UnpackagedLanguageResourcesInLang $Directory Assert-NoDevelopmentArtifacts $Directory + Assert-AuthenticatedAssetLayout $Directory } function Assert-UnpackagedLanguageResourcesInLang([string] $Directory) { @@ -1274,8 +1304,14 @@ function Assert-ToolUiPayload([string] $Directory) { } $files = @(Get-ChildItem -LiteralPath $folder -Filter '*.dat' -File -ErrorAction SilentlyContinue) - if ($files.Count -lt 202) { - throw "Release tool UI payload is incomplete in '$Directory': expected at least 202 .dat definitions, found $($files.Count)" + $expectedFolder = Join-Path $Root 'assets\data\tool-ui' + $expectedNames = @(Get-ChildItem -LiteralPath $expectedFolder -Filter '*.dat' -File -ErrorAction SilentlyContinue | + ForEach-Object Name | Sort-Object) + $actualNames = @($files | ForEach-Object Name | Sort-Object) + $difference = @(Compare-Object -ReferenceObject $expectedNames -DifferenceObject $actualNames) + if ($expectedNames.Count -eq 0 -or $difference.Count -gt 0) { + $details = $difference | ForEach-Object { "$($_.SideIndicator) $($_.InputObject)" } + throw "Release tool UI payload does not exactly match the repository definitions in '$Directory': $($details -join ', ')" } foreach ($file in $files) { @@ -1286,6 +1322,47 @@ function Assert-ToolUiPayload([string] $Directory) { } } +function Assert-AuthenticatedAssetLayout([string] $Directory, [switch] $AllowMsixLogos) { + $assetsRoot = Join-Path $Directory 'Assets' + $runtimePackage = Join-Path $assetsRoot 'runtime-assets.dat' + $referencePackage = Join-Path $assetsRoot 'data\ymhut-data.ybin' + foreach ($package in @($runtimePackage, $referencePackage)) { + if (-not (Test-Path -LiteralPath $package -PathType Leaf)) { + throw "Authenticated asset package is missing from '$Directory': $package" + } + + $bytes = [IO.File]::ReadAllBytes($package) + if ($bytes.Length -lt 97 -or [Text.Encoding]::ASCII.GetString($bytes, 0, 8) -ne 'YMHUTAS2' -or $bytes[8] -ne 2) { + throw "Authenticated asset package has an invalid header: $package" + } + } + + $allowedPng = if ($AllowMsixLogos) { + @( + 'Assets/StoreLogo.png', + 'Assets/Square44x44Logo.png', + 'Assets/Square150x150Logo.png', + 'Assets/Wide310x150Logo.png' + ) + } else { @() } + $blockedExtensions = @( + '.bmp', '.css', '.csv', '.gif', '.glb', '.gltf', '.htm', '.html', '.ico', + '.jpeg', '.jpg', '.js', '.json', '.mjs', '.otf', '.svg', '.ttf', '.wasm', + '.webp', '.woff', '.woff2', '.xls', '.xlsx' + ) + $blocked = @(Get-ChildItem -LiteralPath $assetsRoot -Recurse -File -ErrorAction SilentlyContinue | Where-Object { + $relative = 'Assets/' + ($_.FullName.Substring($assetsRoot.Length).TrimStart('\', '/') -replace '\\', '/') + ($blockedExtensions -contains $_.Extension.ToLowerInvariant()) -or + ($_.Extension -ieq '.png' -and $allowedPng -notcontains $relative) + }) + if ($blocked.Count -gt 0) { + $relativeList = $blocked | ForEach-Object { + 'Assets/' + ($_.FullName.Substring($assetsRoot.Length).TrimStart('\', '/') -replace '\\', '/') + } + throw "Publish Assets contains replaceable plaintext static files: $($relativeList -join ', ')" + } +} + function Copy-PublishToLatest { Stop-ProcessesFromDirectory $LatestRoot Reset-DirectoryInsideRepo $LatestRoot @@ -1293,6 +1370,7 @@ function Copy-PublishToLatest { Normalize-PublishLayout $LatestRoot Assert-MusicApiPayload $LatestRoot Assert-ToolUiPayload $LatestRoot + Assert-AuthenticatedAssetLayout $LatestRoot Write-LayoutManifests $LatestRoot } @@ -1305,6 +1383,7 @@ function Publish-UnpackagedApp([object] $VersionInfo) { '--self-contained', 'true', '--no-restore', '-p:WindowsPackageType=None', + '-p:GenerateAppxPackageOnBuild=false', '-p:PublishSingleFile=false', "-p:AssemblyVersion=$($VersionInfo.PackageVersion)", "-p:FileVersion=$($VersionInfo.PackageVersion)", @@ -1314,6 +1393,7 @@ function Publish-UnpackagedApp([object] $VersionInfo) { Normalize-PublishLayout $PublishRoot Assert-MusicApiPayload $PublishRoot Assert-ToolUiPayload $PublishRoot + Assert-AuthenticatedAssetLayout $PublishRoot Assert-PerMonitorV2Manifest (Join-Path $PublishRoot 'YMhutBox.exe') Write-LayoutManifests $PublishRoot Copy-PublishToLatest @@ -1387,6 +1467,39 @@ function Build-InstallerBootstrap([object] $VersionInfo, [string] $SignTool) { Invoke-ToolQuiet $setupPath @('/WINDOWSELFTEST') 'WinUI installer window navigation self-test failed' " Installer window self-test: $setupPath" } +function Build-UninstallerBootstrap([object] $VersionInfo, [string] $SignTool) { + $bootstrapRoot = Join-Path $Root 'build\winui\uninstaller-bootstrap' + Reset-DirectoryInsideRepo $bootstrapRoot + Invoke-DotNet @( + 'publish', $InstallerBootstrapProject, + '-c', $Configuration, + '-r', 'win-x64', + '--self-contained', 'true', + '--no-restore', + '-p:BootstrapMode=Uninstall', + '-p:AssemblyName=unins000', + "-p:Version=$($VersionInfo.PackageVersion)", + "-p:FileVersion=$($VersionInfo.PackageVersion)", + "-p:InformationalVersion=$($VersionInfo.PackageVersion)", + '-o', $bootstrapRoot + ) + + $bootstrap = Join-Path $bootstrapRoot 'unins000.exe' + if (-not (Test-Path -LiteralPath $bootstrap -PathType Leaf)) { + throw "WinUI uninstaller bootstrap was not produced: $bootstrap" + } + if ($SignTool) { + Sign-Artifact $SignTool $bootstrap + } + Assert-PerMonitorV2Manifest $bootstrap + + foreach ($payloadRoot in @($PublishRoot, $LatestRoot)) { + Copy-Item -LiteralPath $bootstrap -Destination (Join-Path $payloadRoot 'unins000.exe') -Force + Write-LayoutManifests $payloadRoot + } + Write-Host " Uninstaller bootstrap: $(Join-Path $LatestRoot 'unins000.exe')" +} + function Wait-FileWritable([string] $Path, [int] $Attempts = 20, [int] $DelayMilliseconds = 250) { for ($attempt = 1; $attempt -le $Attempts; $attempt++) { $stream = $null @@ -1501,8 +1614,11 @@ function Build-MsixPackage([object] $VersionInfo, [string] $MakeAppx, [string] $ Reset-DirectoryInsideRepo $MsixStageRoot Copy-Item -Path (Join-Path $PublishRoot '*') -Destination $MsixStageRoot -Recurse -Force + Remove-Item -LiteralPath (Join-Path $MsixStageRoot 'unins000.exe') -Force -ErrorAction SilentlyContinue + Write-LayoutManifests $MsixStageRoot Assert-MusicApiPayload $MsixStageRoot Assert-ToolUiPayload $MsixStageRoot + Assert-AuthenticatedAssetLayout $MsixStageRoot Assert-PerMonitorV2Manifest (Join-Path $MsixStageRoot 'YMhutBox.exe') Expand-SatelliteResourcePackages $MsixStageRoot -RemovePackages Restore-LangResourceLayoutForMsix $MsixStageRoot @@ -1516,7 +1632,10 @@ function Build-MsixPackage([object] $VersionInfo, [string] $MakeAppx, [string] $ $stageAssets = Join-Path $MsixStageRoot 'Assets' New-Directory $stageAssets - Copy-Item -Path (Join-Path $ProjectAssets '*') -Destination $stageAssets -Recurse -Force + foreach ($logo in @('StoreLogo.png', 'Square44x44Logo.png', 'Square150x150Logo.png', 'Wide310x150Logo.png')) { + Copy-Item -LiteralPath (Join-Path $ProjectAssets $logo) -Destination (Join-Path $stageAssets $logo) -Force + } + Assert-AuthenticatedAssetLayout $MsixStageRoot -AllowMsixLogos Repair-MsixStageThirdPartyPayloads Invoke-ToolQuiet $MakeAppx @('pack', '/d', $MsixStageRoot, '/p', $msixPath, '/o') 'MakeAppx packaging failed' " MSIX package: $msixPath" @@ -1599,10 +1718,15 @@ pause New-Directory $NuGetRoot New-Directory $OutputRoot +New-Directory $BuildTempRoot $env:DOTNET_CLI_HOME = Join-Path $ToolStateRoot 'dotnet' $env:NUGET_PACKAGES = $NuGetRoot $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' +$env:TEMP = $BuildTempRoot +$env:TMP = $BuildTempRoot +$env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $BuildTempRoot 'dotnet-bundle' New-Directory $env:DOTNET_CLI_HOME +New-Directory $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR $versionInfo = Get-VersionInfo $makeAppx = Find-WindowsSdkTool 'MakeAppx.exe' @@ -1637,12 +1761,26 @@ foreach ($ridProject in @($Project, $InstallerBootstrapProject)) { } Publish-UnpackagedApp $versionInfo +Build-UninstallerBootstrap $versionInfo $signTool if ($tauriHostExecutable) { $publishedTauriHost = Join-Path $PublishRoot 'tauri-host\ymhut-box-plugin-tauri-host.exe' if (-not (Test-Path -LiteralPath $publishedTauriHost)) { throw "The Tauri plugin host was built but was not included in the publish payload: $publishedTauriHost" } } + $tauriRuntimeFiles = @( + 'tauri-host\runtime.json', + 'tauri-host\template.ymtemplate', + 'tauri-host\LICENSES.txt', + 'tauri-host\template\dist\index.html', + 'tauri-host\template\README.md' + ) +foreach ($relative in $tauriRuntimeFiles) { + $runtimeFile = Join-Path $PublishRoot $relative + if (-not (Test-Path -LiteralPath $runtimeFile -PathType Leaf)) { + throw "The bundled Tauri runtime dependency is missing from the publish payload: $runtimeFile" + } +} if (($Target -in @('exe', 'both')) -and -not $SkipExe) { $exeBuilt = Build-InnoInstaller $versionInfo $signTool -AllowMissingCompiler:($Target -eq 'both') diff --git a/scripts/generate-app-icons.ps1 b/scripts/generate-app-icons.ps1 new file mode 100644 index 0000000..8847a32 --- /dev/null +++ b/scripts/generate-app-icons.ps1 @@ -0,0 +1,187 @@ +[CmdletBinding()] +param( + [string] $Root = (Split-Path -Parent $PSScriptRoot) +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +$sourcePath = Join-Path $Root 'assets\icons\app_icon.png' +if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + throw "App icon source was not found: $sourcePath" +} + +function New-ResizedBitmap { + param( + [Drawing.Image] $Source, + [int] $Width, + [int] $Height, + [int] $Padding = 0, + [Drawing.Color] $Background = [Drawing.Color]::Transparent, + [switch] $SharpenSmallAlpha + ) + + $bitmap = [Drawing.Bitmap]::new($Width, $Height, [Drawing.Imaging.PixelFormat]::Format32bppArgb) + $graphics = [Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.CompositingMode = [Drawing.Drawing2D.CompositingMode]::SourceCopy + $graphics.CompositingQuality = [Drawing.Drawing2D.CompositingQuality]::HighQuality + $graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.PixelOffsetMode = [Drawing.Drawing2D.PixelOffsetMode]::HighQuality + $graphics.SmoothingMode = [Drawing.Drawing2D.SmoothingMode]::HighQuality + $graphics.Clear($Background) + + $availableWidth = $Width - (2 * $Padding) + $availableHeight = $Height - (2 * $Padding) + $scale = [Math]::Min($availableWidth / $Source.Width, $availableHeight / $Source.Height) + $drawWidth = [Math]::Max(1, [int][Math]::Round($Source.Width * $scale)) + $drawHeight = [Math]::Max(1, [int][Math]::Round($Source.Height * $scale)) + $drawX = [int][Math]::Floor(($Width - $drawWidth) / 2) + $drawY = [int][Math]::Floor(($Height - $drawHeight) / 2) + $attributes = [Drawing.Imaging.ImageAttributes]::new() + try { + $attributes.SetWrapMode([Drawing.Drawing2D.WrapMode]::TileFlipXY) + $graphics.DrawImage( + $Source, + [Drawing.Rectangle]::new($drawX, $drawY, $drawWidth, $drawHeight), + 0, + 0, + $Source.Width, + $Source.Height, + [Drawing.GraphicsUnit]::Pixel, + $attributes) + } finally { + $attributes.Dispose() + } + } finally { + $graphics.Dispose() + } + + if ($SharpenSmallAlpha) { + for ($y = 0; $y -lt $Height; $y++) { + for ($x = 0; $x -lt $Width; $x++) { + $color = $bitmap.GetPixel($x, $y) + if ($color.A -le 10) { + $bitmap.SetPixel($x, $y, [Drawing.Color]::Transparent) + } else { + $alpha = if ($color.A -ge 244) { 255 } else { [Math]::Min(255, [int](($color.A - 10) * 255 / 234)) } + $bitmap.SetPixel($x, $y, [Drawing.Color]::FromArgb($alpha, 0, 114, 255)) + } + } + } + } + + return $bitmap +} + +function Get-PngBytes([Drawing.Bitmap] $Bitmap) { + $stream = [IO.MemoryStream]::new() + try { + $Bitmap.Save($stream, [Drawing.Imaging.ImageFormat]::Png) + return $stream.ToArray() + } finally { + $stream.Dispose() + } +} + +function Write-PngIconFile([string] $Path, [hashtable] $Frames) { + $orderedSizes = @($Frames.Keys | ForEach-Object { [int] $_ } | Sort-Object) + $headerSize = 6 + (16 * $orderedSizes.Count) + $offset = $headerSize + $stream = [IO.MemoryStream]::new() + $writer = [IO.BinaryWriter]::new($stream) + try { + $writer.Write([uint16] 0) + $writer.Write([uint16] 1) + $writer.Write([uint16] $orderedSizes.Count) + foreach ($size in $orderedSizes) { + $bytes = [byte[]] $Frames[$size] + $writer.Write([byte] $(if ($size -eq 256) { 0 } else { $size })) + $writer.Write([byte] $(if ($size -eq 256) { 0 } else { $size })) + $writer.Write([byte] 0) + $writer.Write([byte] 0) + $writer.Write([uint16] 1) + $writer.Write([uint16] 32) + $writer.Write([uint32] $bytes.Length) + $writer.Write([uint32] $offset) + $offset += $bytes.Length + } + foreach ($size in $orderedSizes) { + $writer.Write([byte[]] $Frames[$size]) + } + $writer.Flush() + $directory = Split-Path -Parent $Path + if ($directory) { [IO.Directory]::CreateDirectory($directory) | Out-Null } + [IO.File]::WriteAllBytes($Path, $stream.ToArray()) + } finally { + $writer.Dispose() + $stream.Dispose() + } +} + +function Save-Logo { + param( + [Drawing.Image] $Source, + [string] $Path, + [int] $Width, + [int] $Height, + [int] $Padding, + [Drawing.Color] $Background = [Drawing.Color]::Transparent + ) + $bitmap = New-ResizedBitmap $Source $Width $Height $Padding $Background + try { + [IO.Directory]::CreateDirectory((Split-Path -Parent $Path)) | Out-Null + $bitmap.Save($Path, [Drawing.Imaging.ImageFormat]::Png) + } finally { + $bitmap.Dispose() + } +} + +$loaded = [Drawing.Bitmap]::FromFile($sourcePath) +try { + $master = [Drawing.Bitmap]::new($loaded) +} finally { + $loaded.Dispose() +} + +try { + $frames = @{} + foreach ($size in @(16, 20, 24, 32, 40, 48, 64, 96, 128, 256)) { + $frame = New-ResizedBitmap $master $size $size 0 ([Drawing.Color]::Transparent) -SharpenSmallAlpha:($size -le 24) + try { + $frames[$size] = Get-PngBytes $frame + } finally { + $frame.Dispose() + } + } + + $iconTargets = @( + (Join-Path $Root 'assets\icon.ico'), + (Join-Path $Root 'assets\icons\app_icon.ico'), + (Join-Path $Root 'src\box-winUI\Assets\app_icon.ico'), + (Join-Path $Root 'src\YMhut.Box.PluginTauriHost\src-tauri\icons\icon.ico') + ) + foreach ($target in $iconTargets) { + Write-PngIconFile $target $frames + } + + $normalizedSource = Join-Path ([IO.Path]::GetTempPath()) ("ymhut-app-icon-{0}.png" -f [Guid]::NewGuid().ToString('N')) + try { + $master.Save($normalizedSource, [Drawing.Imaging.ImageFormat]::Png) + Copy-Item -LiteralPath $normalizedSource -Destination (Join-Path $Root 'assets\icons\app_icon.png') -Force + Copy-Item -LiteralPath $normalizedSource -Destination (Join-Path $Root 'assets\icons\icon.png') -Force + } finally { + Remove-Item -LiteralPath $normalizedSource -Force -ErrorAction SilentlyContinue + } + + $projectAssets = Join-Path $Root 'src\box-winUI\Assets' + Save-Logo $master (Join-Path $projectAssets 'StoreLogo.png') 50 50 5 + Save-Logo $master (Join-Path $projectAssets 'Square44x44Logo.png') 44 44 4 + Save-Logo $master (Join-Path $projectAssets 'Square150x150Logo.png') 150 150 16 + Save-Logo $master (Join-Path $projectAssets 'LockScreenLogo.png') 70 70 8 + Save-Logo $master (Join-Path $projectAssets 'Wide310x150Logo.png') 310 150 24 ([Drawing.Color]::FromArgb(255, 245, 246, 247)) +} finally { + $master.Dispose() +} + +Write-Host 'Generated DPI-aware YMhut Box application icons.' diff --git a/scripts/generate-authenticated-assets.ps1 b/scripts/generate-authenticated-assets.ps1 new file mode 100644 index 0000000..e6821b0 --- /dev/null +++ b/scripts/generate-authenticated-assets.ps1 @@ -0,0 +1,154 @@ +param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) + +$ErrorActionPreference = 'Stop' +$rootPath = (Resolve-Path -LiteralPath $Root).Path +$outputPath = [IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Force -Path $outputPath | Out-Null + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Get-Sha256([byte[]]$Bytes) { + $sha = [Security.Cryptography.SHA256]::Create() + try { return ([BitConverter]::ToString($sha.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant() } + finally { $sha.Dispose() } +} + +function Get-KeyMaterial([byte[]]$Salt, [string]$Purpose) { + $purposeBytes = [Text.Encoding]::UTF8.GetBytes("YMhut.Box.AssetPackage.v2|$Purpose") + $material = [byte[]]::new($purposeBytes.Length + $Salt.Length) + [Array]::Copy($purposeBytes, 0, $material, 0, $purposeBytes.Length) + [Array]::Copy($Salt, 0, $material, $purposeBytes.Length, $Salt.Length) + $sha = [Security.Cryptography.SHA512]::Create() + try { return $sha.ComputeHash($material) } + finally { $sha.Dispose() } +} + +function Write-AuthenticatedPackage([byte[]]$Plain, [string]$Purpose, [string]$Path) { + $salt = [byte[]]::new(16) + $iv = [byte[]]::new(16) + $random = [Security.Cryptography.RandomNumberGenerator]::Create() + try { + $random.GetBytes($salt) + $random.GetBytes($iv) + } + finally { $random.Dispose() } + $keys = Get-KeyMaterial $salt $Purpose + $aes = [Security.Cryptography.Aes]::Create() + try { + $aes.Key = $keys[0..31] + $aes.IV = $iv + $aes.Mode = [Security.Cryptography.CipherMode]::CBC + $aes.Padding = [Security.Cryptography.PaddingMode]::PKCS7 + $encryptor = $aes.CreateEncryptor() + try { $cipher = $encryptor.TransformFinalBlock($Plain, 0, $Plain.Length) } + finally { $encryptor.Dispose() } + } + finally { $aes.Dispose() } + + $magic = [Text.Encoding]::ASCII.GetBytes('YMHUTAS2') + $header = [IO.MemoryStream]::new() + $writer = [IO.BinaryWriter]::new($header, [Text.Encoding]::UTF8, $true) + try { + $writer.Write($magic) + $writer.Write([byte]2) + $writer.Write($salt) + $writer.Write($iv) + $writer.Write([long]$Plain.Length) + $writer.Write([long]$cipher.Length) + $writer.Flush() + } + finally { $writer.Dispose() } + + $signed = [byte[]]::new($header.Length + $cipher.Length) + [Array]::Copy($header.ToArray(), 0, $signed, 0, $header.Length) + [Array]::Copy($cipher, 0, $signed, $header.Length, $cipher.Length) + $hmac = [Security.Cryptography.HMACSHA256]::new($keys[32..63]) + try { $tag = $hmac.ComputeHash($signed) } + finally { $hmac.Dispose() } + [IO.File]::WriteAllBytes($Path, $signed + $tag) + $header.Dispose() +} + +function Add-SourceFile([hashtable]$Entries, [string]$Source, [string]$EntryName) { + if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) { throw "Missing authenticated asset source: $Source" } + $normalized = $EntryName.Replace('\', '/').TrimStart('/') + if ($normalized -eq '' -or $normalized.Contains('../') -or $normalized.StartsWith('../')) { throw "Unsafe authenticated asset entry: $EntryName" } + if ($Entries.ContainsKey($normalized)) { throw "Duplicate authenticated asset entry: $normalized" } + $bytes = [IO.File]::ReadAllBytes($Source) + $Entries[$normalized] = [pscustomobject]@{ Path = $normalized; Bytes = $bytes; Sha256 = Get-Sha256 $bytes } +} + +function Add-SourceTree([hashtable]$Entries, [string]$SourceRoot, [string]$Prefix, [scriptblock]$Include = $null) { + if (-not (Test-Path -LiteralPath $SourceRoot -PathType Container)) { throw "Missing authenticated asset directory: $SourceRoot" } + foreach ($file in Get-ChildItem -LiteralPath $SourceRoot -Recurse -File) { + $relative = $file.FullName.Substring($SourceRoot.TrimEnd('\', '/').Length).TrimStart('\', '/').Replace('\', '/') + if ($Include -and -not (& $Include $relative)) { continue } + Add-SourceFile $Entries $file.FullName (("$Prefix/$relative").TrimStart('/')) + } +} + +function New-AssetPackage([hashtable]$Entries, [string]$BundleId, [string]$Purpose, [string]$Path) { + $memory = [IO.MemoryStream]::new() + $archive = [IO.Compression.ZipArchive]::new($memory, [IO.Compression.ZipArchiveMode]::Create, $true) + try { + foreach ($item in ($Entries.Values | Sort-Object Path)) { + $entry = $archive.CreateEntry($item.Path, [IO.Compression.CompressionLevel]::Optimal) + $stream = $entry.Open() + try { $stream.Write($item.Bytes, 0, $item.Bytes.Length) } + finally { $stream.Dispose() } + } + $manifest = [ordered]@{ + schemaVersion = 2 + bundleId = $BundleId + files = @($Entries.Values | Sort-Object Path | ForEach-Object { + [ordered]@{ path = $_.Path; length = $_.Bytes.Length; sha256 = $_.Sha256 } + }) + } + $manifestBytes = [Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Depth 5 -Compress)) + $manifestEntry = $archive.CreateEntry('_manifest.json', [IO.Compression.CompressionLevel]::Optimal) + $manifestStream = $manifestEntry.Open() + try { $manifestStream.Write($manifestBytes, 0, $manifestBytes.Length) } + finally { $manifestStream.Dispose() } + } + finally { $archive.Dispose() } + $plain = $memory.ToArray() + $memory.Dispose() + Write-AuthenticatedPackage $plain $Purpose $Path +} + +$runtimeEntries = @{} +$projectAssets = Join-Path $rootPath 'src\box-winUI\Assets' +$repoAssets = Join-Path $rootPath 'assets' +Add-SourceTree $runtimeEntries (Join-Path $projectAssets 'startup-splash') 'startup-splash' +Add-SourceTree $runtimeEntries (Join-Path $projectAssets 'home-globe') 'home-globe' { + param($relative) + return $relative -notlike 'textures/solar/source/*' -and + $relative -notlike 'textures/solar/optimized/*' -and + $relative -ne 'textures/solar/solar-texture-manifest.json' +} +Add-SourceTree $runtimeEntries (Join-Path $projectAssets 'tool-pages') 'tool-pages' +Add-SourceTree $runtimeEntries (Join-Path $projectAssets 'tool-results') 'tool-results' +Add-SourceTree $runtimeEntries (Join-Path $repoAssets 'icons') 'icons' { param($relative) return $relative -ne '.gitkeep' } +Add-SourceTree $runtimeEntries (Join-Path $repoAssets 'images') 'images' { param($relative) return $relative -ne 'loading.gif' } +Add-SourceFile $runtimeEntries (Join-Path $repoAssets 'download.svg') 'download.svg' +Add-SourceFile $runtimeEntries (Join-Path $repoAssets 'icons\app_icon.ico') 'app_icon.ico' +New-AssetPackage $runtimeEntries 'runtime-assets' 'runtime-assets' (Join-Path $outputPath 'runtime-assets.dat') + +$dataEntries = @{} +$dataRoot = Join-Path $repoAssets 'data' +foreach ($file in Get-ChildItem -LiteralPath $dataRoot -Recurse -File) { + if ($file.Extension -ieq '.dat' -or $file.Name -ieq 'ymhut-data.ybin') { continue } + $relative = $file.FullName.Substring($repoAssets.TrimEnd('\', '/').Length).TrimStart('\', '/').Replace('\', '/') + Add-SourceFile $dataEntries $file.FullName $relative +} +$rankingRoot = Join-Path $rootPath 'server\unified-management\internal\reference\data' +if (Test-Path -LiteralPath $rankingRoot -PathType Container) { + foreach ($file in Get-ChildItem -LiteralPath $rankingRoot -Filter '*.json' -File) { + Add-SourceFile $dataEntries $file.FullName ("data/rankings/$($file.Name)") + } +} +New-AssetPackage $dataEntries 'reference-data' 'reference-data' (Join-Path $outputPath 'ymhut-data.ybin') diff --git a/scripts/generate-pearapi-pages.ps1 b/scripts/generate-pearapi-pages.ps1 new file mode 100644 index 0000000..a95aa60 --- /dev/null +++ b/scripts/generate-pearapi-pages.ps1 @@ -0,0 +1,64 @@ +param( + [string]$Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +) + +$ErrorActionPreference = 'Stop' +$catalogPath = Join-Path $Root 'src\YMhut.Box.Core\Api\PearApiCatalog.cs' +$outputPath = Join-Path $Root 'src\box-winUI\Views\Tools\GeneratedPearApiToolPages.cs' +$pattern = 'D\("(?[^"]+)",\s*\d+,' +$ids = [Collections.Generic.List[string]]::new() +foreach ($line in (Get-Content -LiteralPath $catalogPath -Encoding UTF8)) { + $match = [regex]::Match($line, $pattern) + if ($match.Success) { $ids.Add($match.Groups['id'].Value) } +} +if ($ids.Count -ne 74) { throw "Expected 74 PearAPI definitions, found $($ids.Count)." } +if (($ids | Select-Object -Unique).Count -ne $ids.Count) { throw 'PearAPI tool IDs must be unique.' } +$legacyAliases = @('baidu_hot', 'bili_hot', 'zhihu_hot') + +function Get-ClassStem([string]$id) { + $parts = $id -split '[^A-Za-z0-9]+' + return (($parts | Where-Object { $_ } | ForEach-Object { + if ($_.Length -eq 1) { $_.ToUpperInvariant() } + else { $_.Substring(0, 1).ToUpperInvariant() + $_.Substring(1) } + }) -join '') +} + +$lines = [Collections.Generic.List[string]]::new() +$lines.Add('// ') +$lines.Add('#nullable enable') +$lines.Add('using YMhut.Box.Core.Api;') +$lines.Add('using YMhut.Box.Core.Tools;') +$lines.Add('using YMhut.Box.WinUI.ViewModels.Tools;') +$lines.Add('') +$lines.Add('namespace YMhut.Box.WinUI.Views.Tools;') +$lines.Add('') +$lines.Add('public sealed partial class ToolPageRegistry') +$lines.Add('{') +$lines.Add(' private static partial void RegisterPearApi(ToolPageRegistry registry)') +$lines.Add(' {') +foreach ($id in $ids) { + $stem = Get-ClassStem $id + $lines.Add(" registry.Register<${stem}PearApiToolPage, ${stem}PearApiToolViewModel>(`"$id`", (module, goBack) => new ${stem}PearApiToolPage(module, goBack));") +} +foreach ($id in $legacyAliases) { + $stem = (($id -split '_') | ForEach-Object { if ($_.Length -gt 0) { $_.Substring(0, 1).ToUpperInvariant() + $_.Substring(1) } }) -join '' + $lines.Add(" registry.Register<${stem}PearApiToolPage, ${stem}PearApiToolViewModel>(`"$id`", (module, goBack) => new ${stem}PearApiToolPage(module, goBack));") +} +$lines.Add(' }') +$lines.Add('}') +$lines.Add('') +foreach ($id in $ids) { + $stem = Get-ClassStem $id + $lines.Add("public sealed class ${stem}PearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired(`"$id`"));") + $lines.Add("public sealed class ${stem}PearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new ${stem}PearApiToolViewModel(module), goBack);") + $lines.Add('') +} +foreach ($id in $legacyAliases) { + $stem = (($id -split '_') | ForEach-Object { if ($_.Length -gt 0) { $_.Substring(0, 1).ToUpperInvariant() + $_.Substring(1) } }) -join '' + $lines.Add("public sealed class ${stem}PearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired(`"$id`"));") + $lines.Add("public sealed class ${stem}PearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new ${stem}PearApiToolViewModel(module), goBack);") + $lines.Add('') +} + +Set-Content -LiteralPath $outputPath -Value $lines -Encoding UTF8 +Write-Host "Generated $($ids.Count) PearAPI pages and $($legacyAliases.Count) legacy aliases in $outputPath" diff --git a/scripts/generate-tool-ui-dat.ps1 b/scripts/generate-tool-ui-dat.ps1 index 5a34285..9ee6e5d 100644 --- a/scripts/generate-tool-ui-dat.ps1 +++ b/scripts/generate-tool-ui-dat.ps1 @@ -5,6 +5,7 @@ $ErrorActionPreference = 'Stop' $catalogPath = Join-Path $Root 'src\YMhut.Box.Core\Tools\ToolCatalog.cs' +$pearCatalogPath = Join-Path $Root 'src\YMhut.Box.Core\Api\PearApiCatalog.cs' $nexPath = Join-Path $Root 'src\YMhut.Box.Core\Tools\NexNativeTools.cs' $outputRoot = Join-Path $Root 'assets\data\tool-ui' New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null @@ -32,6 +33,35 @@ foreach ($line in ($rawMatch.Groups['data'].Value -split "`r?`n")) { } } +$pearPattern = 'D\("(?[^"]+)",\s*(?\d+),\s*"(?[^"]+)",\s*"(?[^"]*)",\s*"(?[^"]+)",\s*"(?[^"]+)",\s*PearApiRequestMethod\.(?\w+),\s*PearApiPrivacyClass\.(?\w+),\s*PearApiResultKind\.(?\w+)' +foreach ($line in (Get-Content -LiteralPath $pearCatalogPath -Encoding UTF8)) { + $match = [regex]::Match($line, $pearPattern) + if (-not $match.Success) { continue } + $id = $match.Groups['id'].Value + [void]$ids.Add($id) + $metadata[$id] = [ordered]@{ + zh = $match.Groups['name'].Value + en = $match.Groups['name'].Value + descriptionZh = $match.Groups['description'].Value + descriptionEn = $match.Groups['description'].Value + category = 'network' + offline = $false + pear = $true + pearCategory = $match.Groups['category'].Value + privacy = $match.Groups['privacy'].Value + result = $match.Groups['result'].Value + apiId = $match.Groups['apiId'].Value + } +} +foreach ($alias in @{ baidu_hot = '百度'; bili_hot = '哔哩哔哩'; zhihu_hot = '知乎' }.GetEnumerator()) { + if (-not $metadata.ContainsKey($alias.Key)) { continue } + $metadata[$alias.Key].pear = $true + $metadata[$alias.Key].pearCategory = '网络' + $metadata[$alias.Key].privacy = 'Public' + $metadata[$alias.Key].result = 'RankedList' + $metadata[$alias.Key].apiId = '141' +} + foreach ($match in [regex]::Matches((Get-Content -LiteralPath $nexPath -Raw -Encoding UTF8), 'Tool\("(?[^"]+)"')) { $id = $match.Groups['id'].Value [void]$ids.Add($id) @@ -59,7 +89,13 @@ try { $meta = $metadata[$id] $category = [string]$meta.category $risk = if ($category -in @('security', 'system', 'plugin', 'external')) { 'high' } else { 'normal' } - $primaryKind = switch ($category) { + $primaryKind = if ($meta.pear) { + switch ([string]$meta.privacy) { + 'Public' { 'Output'; break } + 'FileUpload' { 'FilePicker'; break } + default { 'Query' } + } + } else { switch ($category) { 'image' { 'FilePicker'; break } 'calculator' { 'Number'; break } 'network' { 'Query'; break } @@ -69,12 +105,12 @@ try { 'plugin' { 'WebView'; break } 'external' { 'ExternalSurface'; break } default { 'Text' } - } + } } $maxLength = 4096 if ($category -eq 'text' -or $category -eq 'dev' -or $category -eq 'security') { $maxLength = 200000 } $placeholder = '输入此工具所需的值' - if ($category -eq 'network') { $placeholder = '关键词、URL、域名或 IP' } + if ($category -eq 'network') { $placeholder = '按此接口页面的字段填写查询条件' } $primaryControls = [Collections.Generic.List[object]]::new() $primaryControls.Add([ordered]@{ id = 'primary' @@ -88,7 +124,9 @@ try { }) $sections = [Collections.Generic.List[object]]::new() - $primaryTitle = switch ($category) { + $primaryTitle = if ($meta.pear) { + [ordered]@{ zh = "PearAPI · $([string]$meta.pearCategory)"; en = "PearAPI · $([string]$meta.pearCategory)" } + } else { switch ($category) { 'calculator' { [ordered]@{ zh = '计算参数'; en = 'Calculation parameters' } } 'network' { [ordered]@{ zh = '查询条件'; en = 'Query criteria' } } 'image' { [ordered]@{ zh = '文件与媒体'; en = 'Files and media' } } @@ -99,14 +137,14 @@ try { 'plugin' { [ordered]@{ zh = '插件安全容器'; en = 'Plugin security container' } } 'external' { [ordered]@{ zh = '外部程序运行'; en = 'External program launch' } } default { [ordered]@{ zh = '主要操作'; en = 'Primary operation' } } - } + } } if ($category -eq 'calculator') { $primaryControls.Clear() foreach ($numberId in @('number-primary', 'number-secondary', 'number-third', 'number-fourth')) { $primaryControls.Add([ordered]@{ id = $numberId; kind = 'Number'; label = [ordered]@{ zh = '数值参数'; en = 'Numeric parameter' }; defaultValue = '1'; required = $true; isReadOnly = $false; validation = [ordered]@{ minimum = -100000000; maximum = 100000000 } }) } - } elseif ($category -eq 'network') { + } elseif ($category -eq 'network' -and -not $meta.pear) { $primaryControls.Add([ordered]@{ id = 'parameter'; kind = 'ComboBox'; label = [ordered]@{ zh = '来源或分类'; en = 'Source or category' }; defaultValue = ''; required = $false; isReadOnly = $false }) } elseif ($category -eq 'image') { $primaryControls.Add([ordered]@{ id = 'file'; kind = 'FilePicker'; label = [ordered]@{ zh = '文件'; en = 'File' }; placeholder = '选择本地文件'; defaultValue = ''; required = $false; isReadOnly = $false }) @@ -121,7 +159,7 @@ try { layout = $(if ($category -eq 'calculator') { 'grid' } else { 'stack' }) }) - if ($category -in @('text', 'dev', 'security', 'network', 'design')) { + if (-not $meta.pear -and $category -in @('text', 'dev', 'security', 'network', 'design')) { $advancedControls = [Collections.Generic.List[object]]::new() if ($category -in @('text', 'dev', 'security')) { $advancedControls.Add([ordered]@{ id = 'rules'; kind = 'ComboBox'; label = [ordered]@{ zh = '处理规则'; en = 'Processing rules' }; defaultValue = ''; required = $false; isReadOnly = $false }) @@ -142,7 +180,9 @@ try { }) } - $resultKind = switch ($category) { + $resultKind = if ($meta.pear) { + "pear-$(([string]$meta.result).ToLowerInvariant())" + } else { switch ($category) { 'calculator' { 'calculator-table' } 'image' { 'image-preview' } 'data' { 'ranked-list' } @@ -153,7 +193,7 @@ try { 'plugin' { 'status-list' } 'external' { 'status-list' } default { 'text' } - } + } } $actions = [Collections.Generic.List[object]]::new() $actionLabel = switch ($category) { 'calculator' { [ordered]@{ zh = '计算'; en = 'Calculate' } } @@ -217,5 +257,10 @@ try { } finally { $aes.Dispose() } +foreach ($stale in Get-ChildItem -LiteralPath $outputRoot -Filter '*.dat' -File -ErrorAction SilentlyContinue) { + if (-not $ids.Contains($stale.BaseName)) { + Remove-Item -LiteralPath $stale.FullName -Force + } +} Write-Host "Generated $($ids.Count) tool UI definitions in $outputRoot" diff --git a/src/YMhut.Box.Core/Api/PearApiCatalog.cs b/src/YMhut.Box.Core/Api/PearApiCatalog.cs new file mode 100644 index 0000000..836d936 --- /dev/null +++ b/src/YMhut.Box.Core/Api/PearApiCatalog.cs @@ -0,0 +1,208 @@ +using YMhut.Box.Core.Tools; + +namespace YMhut.Box.Core.Api; + +public static class PearApiCatalog +{ + public const string ProviderName = "PearAPI"; + public const string DocumentationUrl = "https://api.pearapi.ai/zh/dashboard/api-list"; + public const string DisclaimerUrl = "https://api.pearapi.ai/zh/disclaimer"; + public const string CatalogVerifiedAt = "2026-08-19"; + + private static readonly IReadOnlyList Definitions = + [ + D("ai_latest_news", 325, "AI 全网最新资讯", "最近 24 小时 AI 模型、研究、工具和产业资讯。", "AI", "/api/latest_ai_consultative", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.News, false, "PearAPI 当前文档把 key 标为必填;YMhut Box 不会索取或发送 Key。", P("key", "Key", "当前文档要求的 Key", required: true)), + D("pear_ai_image_describe", 221, "AI 识图", "上传图片并识别画面主体和内容。", "AI", "/api/airecognizeimg/", PearApiRequestMethod.Multipart, PearApiPrivacyClass.FileUpload, PearApiResultKind.Cards, parameters: [P("file", "图片", "发送至 PearAPI 的图片", PearApiParameterKind.File, true)]), + D("pear_embeddings", 334, "文本向量化", "将文本转换为指定维度的向量。", "AI", "/v1/embeddings", PearApiRequestMethod.PostJson, PearApiPrivacyClass.UserText, PearApiResultKind.Json, false, "该接口位于 /v1/ 且不符合客户端仅允许 /api/ 的固定路径策略。", P("input", "文本", "待向量化文本", PearApiParameterKind.MultilineText, true), P("model", "模型", "向量模型", PearApiParameterKind.Choice, true, "Qwen3-Embedding-0.6B", ["Qwen3-Embedding-8B", "Qwen3-Embedding-0.6B"]), P("dimensions", "维度", "32 至 4096", PearApiParameterKind.Number, true, "1024")), + D("pear_ai_image_detection", 333, "AI 图片鉴定", "检测图片是否可能由 AI 生成。", "AI", "/api/image_identification_chart", PearApiRequestMethod.Multipart, PearApiPrivacyClass.FileUpload, PearApiResultKind.Cards, parameters: [P("image", "图片", "发送至 PearAPI 的图片", PearApiParameterKind.File, true)]), + D("pear_ai_text_safety", 332, "AI 文本合规检测", "检测输入文本的内容风险。", "AI", "/api/text_security", PearApiRequestMethod.PostJson, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("text", "待检测文本", "不会写入本地缓存", PearApiParameterKind.MultilineText, true, maxLength: 12000)]), + D("pear_ai_dish_recognition", 324, "AI 菜品识别", "上传菜品图片并识别名称。", "AI", "/api/aidish/", PearApiRequestMethod.Multipart, PearApiPrivacyClass.FileUpload, PearApiResultKind.Cards, parameters: [P("file", "菜品图片", "发送至 PearAPI 的图片", PearApiParameterKind.File, true)]), + D("pear_image_prompt", 317, "图片提示词反推", "从图片生成 Flux、Stable Diffusion 或 Midjourney 提示词。", "AI", "/api/prompt_image", PearApiRequestMethod.Multipart, PearApiPrivacyClass.FileUpload, PearApiResultKind.Text, parameters: [P("file", "参考图片", "发送至 PearAPI 的图片", PearApiParameterKind.File, true), P("type", "提示词类型", "生成模型格式", PearApiParameterKind.Choice, true, "flux", ["flux", "stablediffusion", "midjourney", "normal"]), P("lang", "语言", "提示词语言", PearApiParameterKind.Choice, true, "zh", ["zh", "en"])]), + D("ai_translation", 303, "AI 万能翻译", "使用 AI 翻译文本并指定目标语言。", "AI", "/api/translate/ai/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Text, parameters: [P("text", "原文", "待翻译文字", PearApiParameterKind.MultilineText, true, maxLength: 12000), P("target_lang", "目标语言", "用自然语言描述", defaultValue: "简体中文")]), + D("pear_xf_spark", 285, "讯飞星火 Lite", "向免费 Lite 模型提交单轮问题。", "AI", "/api/xfai/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Text, parameters: [P("message", "问题", "单轮问题内容", PearApiParameterKind.MultilineText, true, maxLength: 8000)]), + D("pear_llm_reader", 284, "AI 网页阅读", "读取公网网页并提炼主要内容。", "AI", "/api/llmreader/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserUrl, PearApiResultKind.Text, parameters: [P("url", "网页地址", "仅允许 HTTP(S) 公网地址", PearApiParameterKind.Url, true), P("type", "返回格式", "结果格式", PearApiParameterKind.Choice, false, "json", ["json", "text"])]), + + D("pear_hot_60_image", 156, "热榜 60 秒图片", "生成百度或微博热榜图片。", "生活", "/api/60s/image/hot/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Image, parameters: [P("type", "平台", "热榜来源", PearApiParameterKind.Choice, true, "baidu", ["baidu", "weibo"])]), + D("pear_daily_60_image", 155, "每日 60 秒图片", "获取当天 60 秒新闻图片。", "生活", "/api/60s/image/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Image), + D("hotboard", 141, "综合热榜", "按平台查看实时热门内容。", "网络", "/api/dailyhot/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.RankedList, parameters: [P("title", "平台", "榜单平台", PearApiParameterKind.Choice, true, "百度", ["百度", "哔哩哔哩", "知乎", "微博", "今日头条", "少数派", "IT之家", "36氪"])]), + D("pear_world_60", 22, "60 秒读世界", "获取当天世界资讯摘要。", "实用", "/api/60s/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.News), + D("oil_price", 345, "今日油价", "查询省份 92、95、98 号汽油和 0 号柴油价格。", "生活", "/api/oilprice", PearApiRequestMethod.Get, PearApiPrivacyClass.Financial, PearApiResultKind.Table, parameters: [P("type", "模式", "查询模式", PearApiParameterKind.Choice, true, "get", ["get", "list"]), P("province", "省份", "省级行政区名称", required: true)]), + D("pear_life_calculator", 340, "人生计算器", "根据出生日期量化人生时间进度。", "生活", "/api/life_calculator", PearApiRequestMethod.PostJson, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("birth_date", "出生日期", "格式 YYYY-MM-DD", PearApiParameterKind.Date, true)]), + D("pear_random_superpower", 327, "随机超能力", "随机生成一种能力和对应限制。", "娱乐", "/api/superpower", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Image), + D("pear_random_song", 318, "随机点歌", "随机返回一段唱鸭点歌内容。", "娱乐", "/api/click_music", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Audio), + D("pear_text_tts", 316, "通用文本配音", "用指定音色和风格生成语音。", "实用", "/api/freedub", PearApiRequestMethod.PostJson, PearApiPrivacyClass.UserText, PearApiResultKind.Audio, parameters: [P("text", "配音文本", "发送至 PearAPI 的文本", PearApiParameterKind.MultilineText, true, maxLength: 3000), P("role", "音色", "留空使用默认音色"), P("style", "风格", "语音风格", defaultValue: "cheerful")]), + D("pear_ip_details", 290, "IP 详情", "查询公网 IP 的国家、地区、城市和时区。", "数据", "/api/ip/details/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("ip", "IP 地址", "IPv4 或 IPv6", required: true)]), + D("pear_gold_price", 286, "今日黄金价格", "查看当天黄金品类价格。", "生活", "/api/goldprice/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Table), + D("movie_box_office", 283, "电影实时票房", "查看猫眼实时票房排行。", "娱乐", "/api/maoyan/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.RankedList), + D("pear_ruozhiba", 282, "随机趣味问答", "随机返回一条弱智吧问答。", "娱乐", "/api/ruozhiba/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Cards), + D("pear_qq_level_image", 280, "QQ 等级图像", "按等级和样式生成 QQ 等级图标。", "图像", "/api/qq/level/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Image, parameters: [P("level", "QQ 等级", "正整数", PearApiParameterKind.Number, true), P("type", "样式", "图标集规则", PearApiParameterKind.Choice, false, "1", ["1", "2", "3", "4"])]), + D("pear_historical_person", 277, "历史人物年轮", "查询历史人物生平和年轮信息。", "学习", "/api/yearsmap/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("name", "人物姓名", "历史人物名", required: true)]), + D("pear_countdown", 275, "倒数日", "获取近期节日和事件倒数日。", "生活", "/api/countdownday/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Cards), + D("pear_answers_book", 274, "答案之书", "输入问题并获取随机回答。", "娱乐", "/api/answersbook/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Text, parameters: [P("question", "问题", "要询问的问题", PearApiParameterKind.MultilineText, true, maxLength: 1000)]), + D("city_route_query", 272, "城际路线查询", "查询城市间距离、耗时、油费和过路费。", "生活", "/api/citytravelroutes/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("from", "出发城市", "城市名称", required: true), P("to", "到达城市", "城市名称", required: true)]), + D("pear_cookbook", 270, "菜谱大全", "按菜名查询简介、材料和做法。", "生活", "/api/cookbook/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("search", "菜名", "具体菜品名称", required: true)]), + D("pear_car_price", 269, "车辆价格查询", "按具体车型查询价格、配置和封面。", "生活", "/api/car/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("search", "车型", "例如:法拉利 296", required: true)]), + D("pear_lottery", 268, "彩票开奖查询", "查询常见彩票开奖数据。", "数据", "/api/lottery/", PearApiRequestMethod.Get, PearApiPrivacyClass.Financial, PearApiResultKind.Table, parameters: [P("get", "彩种", "彩种缩写", PearApiParameterKind.Choice, true, "ssq", ["kl8", "ssq", "dlt", "fc3d", "pl3", "pl5", "qlc", "qxc", "sfc", "jqc", "bqc"]), P("num", "天数", "1 至 100", PearApiParameterKind.Number, false, "30")]), + D("pear_stock", 267, "股票数据查询", "查询股票价格、成交量和涨跌幅。", "数据", "/api/stock/", PearApiRequestMethod.Get, PearApiPrivacyClass.Financial, PearApiResultKind.Table, parameters: [P("secid", "股票代码", "证券代码", required: true), P("num", "记录数", "默认 100", PearApiParameterKind.Number, false, "100")]), + D("pear_trademark", 263, "商标信息查询", "按关键词查询商标申请和注册信息。", "数据", "/api/trademark/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Table, parameters: [P("keyword", "商标关键词", "商标名称关键词", required: true)]), + D("pear_poem", 259, "藏头藏尾诗", "按五言或七言生成藏头、藏尾诗。", "学习", "/api/poem_generate/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Text, parameters: [P("type", "言律", "诗句字数", PearApiParameterKind.Choice, true, "wuyan", ["wuyan", "qiyan"]), P("model", "模式", "藏头或藏尾", PearApiParameterKind.Choice, true, "head", ["head", "trail"]), P("text", "藏字", "用于生成的文字", required: true)]), + D("train_query", 249, "高铁动车车票", "按出发地、目的地和日期查询车票。", "生活", "/api/highspeedticket", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Table, parameters: [P("from", "出发地", "车站或城市", required: true), P("to", "目的地", "车站或城市", required: true), P("time", "日期", "格式 YYYY-MM-DD,留空为今天", PearApiParameterKind.Date)]), + D("pear_certificate", 236, "在线证书生成", "按标题、获奖人和正文生成证书。", "实用", "/api/certificate/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Image, parameters: [P("name", "获奖人", "显示姓名", required: true), P("title", "证书标题", "奖项标题", required: true), P("text", "正文", "证书正文", PearApiParameterKind.MultilineText, true, maxLength: 600)]), + D("pear_ssl", 234, "SSL 证书验证", "查看签发机构、有效期、算法和指纹。", "网络", "/api/ssl/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("domain", "域名", "不含协议的公网域名", PearApiParameterKind.Domain, true)]), + D("pear_icp", 233, "ICP备案查询", "查询域名公开备案信息。", "网络", "/api/icp/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("domain", "域名", "不含 HTTP 协议", PearApiParameterKind.Domain, true)]), + D("pear_blood_type", 231, "血型遗传规律", "根据父母血型计算子代可能血型。", "生活", "/api/bloodtype/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("father", "父亲血型", "血型", PearApiParameterKind.Choice, true, "A", ["A", "B", "AB", "O"]), P("mother", "母亲血型", "血型", PearApiParameterKind.Choice, true, "B", ["A", "B", "AB", "O"])]), + D("pear_brainteaser", 229, "脑筋急转弯", "随机获取一道题目及答案。", "娱乐", "/api/brainteasers/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Cards), + D("pear_synonym_antonym", 224, "近义反义词", "查询词语的近义词和反义词。", "学习", "/api/jinyifanyi/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("word", "词语", "待查询词语", required: true)]), + D("pear_surname_pun", 220, "姓氏谐音网名", "根据姓氏生成谐音网名。", "娱乐", "/api/namexy/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.RankedList, parameters: [P("name", "姓氏", "单个姓氏", required: true, maxLength: 8)]), + D("pear_website_info", 214, "网站基础信息", "读取网站协议、标题、图标、编码和关键词。", "网络", "/api/website/info/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserUrl, PearApiResultKind.Cards, parameters: [P("url", "网站地址", "仅允许 HTTP(S) 公网地址", PearApiParameterKind.Url, true)]), + D("pear_baidu_place", 212, "地名检索", "按关键词检索地点基础信息。", "网络", "/api/baidumap/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Table, parameters: [P("keyword", "地点关键词", "地点名称", required: true)]), + D("pear_audio_transcript", 199, "音视频转文本", "上传音频或视频并识别为文字。", "图像", "/api/audiocr/", PearApiRequestMethod.Multipart, PearApiPrivacyClass.FileUpload, PearApiResultKind.Text, parameters: [P("file", "音视频文件", "发送至 PearAPI,最大 50 MiB", PearApiParameterKind.File, true)]), + D("pear_question_search", 198, "题库搜索", "按题目文本检索公开题库答案。", "学习", "/api/baidutiku/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("question", "题目", "待检索题目", PearApiParameterKind.MultilineText, true, maxLength: 4000)]), + D("pear_funny_joke", 188, "搞笑段子", "随机获取一条搞笑段子。", "娱乐", "/api/random/duanzi/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Text, parameters: [P("type", "格式", "返回格式", PearApiParameterKind.Choice, false, "json", ["json", "text"])]), + D("pear_award", 174, "在线奖状生成", "按姓名、奖项和班级生成奖状。", "实用", "/api/certcommend/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Image, parameters: [P("name", "姓名", "最多 3 个字符", required: true, maxLength: 3), P("title", "奖项", "最多 9 个字符", required: true, maxLength: 9), P("classname", "班级", "班级名称", required: true, maxLength: 40)]), + D("pear_exchange_rate", 173, "货币汇率换算", "按币种和金额执行汇率换算。", "生活", "/api/exchangerate/", PearApiRequestMethod.Get, PearApiPrivacyClass.Financial, PearApiResultKind.Cards, parameters: [P("type", "模式", "get 或 list", PearApiParameterKind.Choice, true, "get", ["get", "list"]), P("before", "原币种", "三位币种代码", required: true), P("after", "目标币种", "三位币种代码", required: true), P("price", "金额", "转换金额", PearApiParameterKind.Number, true)]), + D("pear_science_news", 163, "实时科技资讯", "获取当前最新科技资讯。", "数据", "/api/sciencenews/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.News), + D("pear_kfc", 162, "疯狂星期四文案", "随机获取一条星期四主题文案。", "娱乐", "/api/kfc", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Text, parameters: [P("type", "格式", "返回格式", PearApiParameterKind.Choice, false, "json", ["json", "text"])]), + D("pear_sensitive_words", 159, "敏感词检测", "检测文本中的敏感词并返回替换结果。", "开发", "/api/sensitivewords/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("text", "文本", "待检测文本", PearApiParameterKind.MultilineText, true, maxLength: 12000)]), + D("pear_ocr", 152, "OCR 图像文字识别", "上传图片并识别其中的文字。", "图像", "/api/ocr/", PearApiRequestMethod.Multipart, PearApiPrivacyClass.FileUpload, PearApiResultKind.Text, parameters: [P("file", "图片", "发送至 PearAPI 的图片", PearApiParameterKind.File, true)]), + D("pear_chinese_conversion", 144, "简繁互转", "在简体中文与繁体中文之间转换。", "实用", "/api/conversion/word.php", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Text, parameters: [P("text", "文本", "待转换文本", PearApiParameterKind.MultilineText, true, maxLength: 12000), P("type", "方向", "转换方向", PearApiParameterKind.Choice, true, "jzf", ["jzf", "fzj"])]), + D("history_today", 133, "历史上的今天", "获取今天发生的历史事件。", "学习", "/api/lsjt/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.RankedList, parameters: [P("type", "格式", "返回格式", PearApiParameterKind.Choice, false, "json", ["json", "text"])]), + D("pear_rainbow", 129, "彩虹屁", "随机获取一段夸赞文案。", "娱乐", "/api/caihongpi/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Text), + D("pear_couplet", 128, "对联生成", "根据上联生成对应下联。", "学习", "/api/duilian/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Text, parameters: [P("word", "上联", "输入上联", PearApiParameterKind.MultilineText, true, maxLength: 500)]), + D("pear_constellation", 123, "星座运势", "查询当天星座运势。", "娱乐", "/api/xzys/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Cards, parameters: [P("xz", "星座", "星座名称", PearApiParameterKind.Choice, true, "白羊", ["白羊", "金牛", "双子", "巨蟹", "狮子", "处女", "天秤", "天蝎", "射手", "摩羯", "水瓶", "双鱼"])]), + D("pear_mean_search", 116, "据意查句", "根据意思查找现代文、诗词或名言。", "学习", "/api/meansearch/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.RankedList, parameters: [P("mean", "含义", "想表达的意思", PearApiParameterKind.MultilineText, true, maxLength: 1000), P("type", "范围", "语料范围", PearApiParameterKind.Choice, false, "现代文", ["现代文", "古诗文"])]), + D("pear_country_population", 109, "国家人口排行", "按名次查看国家人口数据。", "数据", "/api/human/rank.php", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Table, parameters: [P("num", "名次数量", "留空返回默认数量", PearApiParameterKind.Number)]), + D("pear_world_population", 108, "世界人口详情", "查看世界人口汇总数据。", "数据", "/api/human/alldata.php", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Cards), + D("pear_keyword_guide", 101, "关键词引导", "按关键词生成相关引导词。", "开发", "/api/guide/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.RankedList, parameters: [P("keyword", "关键词", "检索关键词", required: true)]), + D("pear_movie_lines", 100, "影视台词搜寻", "按台词内容查找对应影视作品。", "数据", "/api/media/lines.php", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Table, parameters: [P("word", "台词", "台词片段", PearApiParameterKind.MultilineText, true, maxLength: 1000), P("page", "页码", "正整数", PearApiParameterKind.Number, true, "1")]), + D("pear_today_food", 94, "今天吃什么", "随机返回一种食物。", "生活", "/api/today/food.php", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Cards), + D("pear_site_index", 93, "网址收录查询", "查询域名在常见搜索站点的收录状态。", "开发", "/api/website/shoulu.php", PearApiRequestMethod.Get, PearApiPrivacyClass.UserUrl, PearApiResultKind.Table, parameters: [P("url", "域名", "不含路径的公网域名", PearApiParameterKind.Domain, true)]), + D("pear_short_recovery", 70, "短链接还原", "显示短链接的原始目标。", "网络", "/api/short/recovery/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserUrl, PearApiResultKind.Cards, parameters: [P("url", "短链接", "仅允许 HTTP(S) 公网地址", PearApiParameterKind.Url, true)]), + D("pear_college", 65, "全国高校查询", "按关键词查询高校信息。", "学习", "/api/college/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Table, parameters: [P("keyword", "高校关键词", "学校或专业关键词", required: true)]), + D("pear_love_reply", 63, "恋爱话术", "根据问题返回沟通建议。", "娱乐", "/api/love/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Text, parameters: [P("question", "问题", "请勿输入个人隐私", PearApiParameterKind.MultilineText, true, maxLength: 1000)]), + D("pear_quirky_dialogue", 29, "趣味对话", "随机返回一段趣味对话。", "娱乐", "/api/jdyl/qpdh.php", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Text), + D("pear_classic_joke", 25, "经典笑话", "随机返回一篇经典笑话。", "娱乐", "/api/jdyl/xiaohua.php", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Text), + D("pear_love_quote", 24, "经典情话", "随机返回一句情话。", "娱乐", "/api/jdyl/qinghua.php", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Text), + D("pear_translation", 13, "万能翻译", "自动识别语言并执行常用语种翻译。", "学习", "/api/translate/", PearApiRequestMethod.Get, PearApiPrivacyClass.UserText, PearApiResultKind.Text, parameters: [P("text", "原文", "待翻译内容", PearApiParameterKind.MultilineText, true, maxLength: 12000), P("type", "方向", "AUTO 或指定方向", defaultValue: "AUTO")]), + D("pear_hitokoto", 7, "随机一言", "随机返回一句名言。", "娱乐", "/api/hitokoto/", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Text), + D("pear_dujitang", 6, "毒鸡汤", "随机返回一句反鸡汤文案。", "娱乐", "/api/dujitang", PearApiRequestMethod.Get, PearApiPrivacyClass.Public, PearApiResultKind.Text) + ]; + + private static readonly IReadOnlyDictionary ById = Definitions + .ToDictionary(definition => definition.ToolId, StringComparer.OrdinalIgnoreCase); + private static readonly IReadOnlyDictionary LegacyAliases = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["baidu_hot"] = ("百度热搜", "通过综合热榜查看百度实时热门内容。", "百度"), + ["bili_hot"] = ("B 站热榜", "通过综合热榜查看哔哩哔哩热门内容。", "哔哩哔哩"), + ["zhihu_hot"] = ("知乎热榜", "通过综合热榜查看知乎实时热门内容。", "知乎") + }; + + public static IReadOnlyList All => Definitions; + + public static IReadOnlyCollection LegacyAliasToolIds { get; } = LegacyAliases.Keys.ToArray(); + + public static bool TryGet(string toolId, out PearApiToolDefinition definition) + { + if (ById.TryGetValue(toolId, out definition!)) + { + return true; + } + + if (!LegacyAliases.TryGetValue(toolId, out var alias) || !ById.TryGetValue("hotboard", out var source)) + { + definition = default!; + return false; + } + + definition = source with + { + ToolId = toolId, + Name = alias.Name, + Description = alias.Description, + Parameters = source.Parameters + .Select(parameter => parameter.Name.Equals("title", StringComparison.OrdinalIgnoreCase) + ? parameter with { DefaultValue = alias.Platform } + : parameter) + .ToArray() + }; + return true; + } + + public static PearApiToolDefinition GetRequired(string toolId) + => TryGet(toolId, out var definition) + ? definition + : throw new KeyNotFoundException($"PearAPI tool definition was not found: {toolId}"); + + public static IEnumerable CreateToolModules(IEnumerable? existingIds = null) + { + var existing = new HashSet(existingIds ?? [], StringComparer.OrdinalIgnoreCase); + return Definitions + .Where(definition => !existing.Contains(definition.ToolId)) + .Select(definition => new ToolModule(new ToolMetadata( + definition.ToolId, + definition.Name, + definition.Description, + MapCategory(definition.Category), + ["PearAPI", definition.Category, definition.Name, "联网工具"], + false, + "\uE774", + 250, + ProviderName, + new ToolOnlineCapabilityInfo( + ProviderName, + definition.PrivacyClass.ToString(), + definition.PrivacyClass != PearApiPrivacyClass.Public, + definition.IsAvailableWithoutKey)))); + } + + private static ToolCategory MapCategory(string category) => category switch + { + "AI" or "数据" or "学习" => ToolCategory.Data, + "网络" => ToolCategory.Network, + "图像" => ToolCategory.Image, + "开发" => ToolCategory.Dev, + _ => ToolCategory.Life + }; + + private static PearApiToolDefinition D( + string toolId, + int apiId, + string name, + string description, + string category, + string endpointPath, + PearApiRequestMethod method, + PearApiPrivacyClass privacy, + PearApiResultKind result, + bool available = true, + string note = "", + params PearApiParameterDefinition[] parameters) + => new( + toolId, + apiId, + name, + description, + category, + endpointPath, + method, + parameters, + privacy, + result, + available, + note, + available && privacy == PearApiPrivacyClass.Public && method == PearApiRequestMethod.Get && parameters.Length == 0 + ? TimeSpan.FromMinutes(2) + : null); + + private static PearApiParameterDefinition P( + string name, + string label, + string description, + PearApiParameterKind kind = PearApiParameterKind.Text, + bool required = false, + string defaultValue = "", + IReadOnlyList? options = null, + int maxLength = 4096) + => new(name, label, description, kind, required, defaultValue, options, maxLength); +} diff --git a/src/YMhut.Box.Core/Api/PearApiClient.cs b/src/YMhut.Box.Core/Api/PearApiClient.cs new file mode 100644 index 0000000..b16564b --- /dev/null +++ b/src/YMhut.Box.Core/Api/PearApiClient.cs @@ -0,0 +1,491 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using YMhut.Box.Core.Logging; + +namespace YMhut.Box.Core.Api; + +public sealed class PearApiClient : IPearApiClient, IDisposable +{ + public static readonly Uri ServiceRoot = new("https://api.pearapi.ai/api/"); + private const int MaximumResponseBytes = 8 * 1024 * 1024; + private const long MaximumImageBytes = 10L * 1024 * 1024; + private const long MaximumMediaBytes = 50L * 1024 * 1024; + private static readonly TimeSpan MinimumRequestInterval = TimeSpan.FromMilliseconds(250); + + private readonly HttpClient _httpClient; + private readonly ILogService? _logService; + private readonly bool _disposeClient; + private readonly SemaphoreSlim _requestGate = new(1, 1); + private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + private DateTimeOffset _lastRequestAt = DateTimeOffset.MinValue; + + public PearApiClient(HttpClient? httpClient = null, ILogService? logService = null) + { + _httpClient = httpClient ?? new HttpClient(new SocketsHttpHandler + { + AllowAutoRedirect = false, + AutomaticDecompression = DecompressionMethods.All, + ConnectTimeout = TimeSpan.FromSeconds(10) + }); + _disposeClient = httpClient is null; + _logService = logService; + _httpClient.Timeout = Timeout.InfiniteTimeSpan; + _httpClient.DefaultRequestHeaders.Authorization = null; + _httpClient.DefaultRequestHeaders.Remove("Authorization"); + _httpClient.DefaultRequestHeaders.Remove("X-API-Key"); + if (!_httpClient.DefaultRequestHeaders.UserAgent.Any()) + { + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("YMhutBox/2.0 PearApiClient"); + } + } + + public async Task SendAsync(PearApiRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (!PearApiCatalog.TryGet(request.ToolId, out var definition)) + { + return Failure(request.ToolId, "未找到联网工具定义。"); + } + + if (!definition.IsAvailableWithoutKey) + { + return Failure(request.ToolId, string.IsNullOrWhiteSpace(definition.AvailabilityNote) + ? "该接口当前不再支持免 Key 调用。" + : definition.AvailabilityNote); + } + + if (!TryCreateAllowedEndpoint(definition.EndpointPath, out var endpoint)) + { + return Failure(request.ToolId, "接口不在 PearAPI 固定安全路径允许列表中。"); + } + + var values = new Dictionary(request.Parameters ?? new Dictionary(), StringComparer.OrdinalIgnoreCase); + var allowedParameters = definition.Parameters + .Where(parameter => !PearApiPresentationSanitizer.IsHiddenField(parameter.Name)) + .Select(parameter => parameter.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (values.Keys.Any(key => !allowedParameters.Contains(key))) + { + return Failure(request.ToolId, "请求包含该工具未声明的参数。"); + } + + var validationError = await ValidateAsync(definition, values, request, cancellationToken).ConfigureAwait(false); + if (validationError is not null) + { + return Failure(request.ToolId, validationError); + } + + var cacheKey = BuildCacheKey(definition, values, request.FilePath); + var cacheDuration = definition.PublicCacheDuration; + if (cacheDuration is { } duration && _cache.TryGetValue(cacheKey, out var cached) && DateTimeOffset.UtcNow - cached.StoredAt < duration) + { + return cached.Response with { IsFromCache = true }; + } + + var stopwatch = Stopwatch.StartNew(); + try + { + await WaitForRateLimitAsync(cancellationToken).ConfigureAwait(false); + using var message = await BuildRequestAsync(definition, endpoint, values, request.FilePath, cancellationToken).ConfigureAwait(false); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(definition.HasUpload ? 90 : 45)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + using var response = await _httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, linked.Token).ConfigureAwait(false); + var bytes = await ReadBoundedContentAsync(response.Content, linked.Token).ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream"; + var isText = contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("xml", StringComparison.OrdinalIgnoreCase); + var content = isText ? Encoding.UTF8.GetString(bytes) : string.Empty; + if (contentType.Contains("json", StringComparison.OrdinalIgnoreCase)) + { + content = PearApiPresentationSanitizer.SanitizeJson(content); + } + stopwatch.Stop(); + var result = new PearApiResponse( + definition.ToolId, + response.IsSuccessStatusCode, + (int)response.StatusCode, + content, + contentType, + response.IsSuccessStatusCode ? null : FriendlyStatus(response.StatusCode), + DateTimeOffset.UtcNow, + stopwatch.Elapsed, + BinaryContent: isText ? null : bytes); + if (result.Success && cacheDuration is not null) + { + _cache[cacheKey] = new CacheEntry(DateTimeOffset.UtcNow, result); + } + + await WriteLogAsync(response.IsSuccessStatusCode ? "Information" : "Warning", definition.ToolId, (int)response.StatusCode, stopwatch.Elapsed, cancellationToken).ConfigureAwait(false); + return result; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return Failure(definition.ToolId, "请求已取消。", stopwatch.Elapsed); + } + catch (OperationCanceledException) + { + return Failure(definition.ToolId, "PearAPI 响应超时。", stopwatch.Elapsed); + } + catch (Exception exception) when (exception is HttpRequestException or IOException or SocketException or InvalidDataException) + { + await WriteFailureLogAsync(definition.ToolId, exception.GetType().Name, stopwatch.Elapsed, cancellationToken).ConfigureAwait(false); + return Failure(definition.ToolId, "PearAPI 暂时不可用,请稍后重试。", stopwatch.Elapsed); + } + } + + public void Dispose() + { + _requestGate.Dispose(); + if (_disposeClient) + { + _httpClient.Dispose(); + } + } + + private static bool TryCreateAllowedEndpoint(string endpointPath, out Uri endpoint) + { + endpoint = default!; + if (string.IsNullOrWhiteSpace(endpointPath) || !endpointPath.StartsWith("/api/", StringComparison.Ordinal)) + { + return false; + } + + if (!Uri.TryCreate(new Uri("https://api.pearapi.ai"), endpointPath, out var candidate) || + !candidate.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !candidate.Host.Equals("api.pearapi.ai", StringComparison.OrdinalIgnoreCase) || + candidate.Port != 443 || + !candidate.AbsolutePath.StartsWith("/api/", StringComparison.Ordinal) || + candidate.UserInfo.Length > 0) + { + return false; + } + + endpoint = candidate; + return true; + } + + private static async Task ValidateAsync( + PearApiToolDefinition definition, + IReadOnlyDictionary values, + PearApiRequest request, + CancellationToken cancellationToken) + { + foreach (var parameter in definition.Parameters.Where(parameter => !PearApiPresentationSanitizer.IsHiddenField(parameter.Name))) + { + values.TryGetValue(parameter.Name, out var value); + value = value?.Trim() ?? string.Empty; + if (parameter.Required && parameter.Kind != PearApiParameterKind.File && value.Length == 0) + { + return $"请填写“{parameter.DisplayName}”。"; + } + + if (value.Length > parameter.MaxLength) + { + return $"“{parameter.DisplayName}”超过允许长度。"; + } + + if (parameter.Options is { Count: > 0 } && value.Length > 0 && !parameter.Options.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + return $"“{parameter.DisplayName}”不是允许的选项。"; + } + + if (parameter.Kind == PearApiParameterKind.Url && value.Length > 0) + { + var urlError = await ValidatePublicUrlAsync(value, cancellationToken).ConfigureAwait(false); + if (urlError is not null) + { + return $"“{parameter.DisplayName}”:{urlError}"; + } + } + + if (parameter.Kind == PearApiParameterKind.Domain && value.Length > 0) + { + var domainError = await ValidatePublicDomainAsync(value, cancellationToken).ConfigureAwait(false); + if (domainError is not null) + { + return $"“{parameter.DisplayName}”:{domainError}"; + } + } + } + + if (!definition.HasUpload) + { + return string.IsNullOrWhiteSpace(request.FilePath) ? null : "该工具不接受文件上传。"; + } + + if (string.IsNullOrWhiteSpace(request.FilePath)) + { + var fileRequired = definition.Parameters.Any(parameter => parameter.Kind == PearApiParameterKind.File && parameter.Required); + return fileRequired ? "请选择要上传的文件。" : null; + } + + if (!request.UploadConfirmed) + { + return "上传前必须确认文件将发送至 PearAPI。"; + } + + return ValidateUpload(request.FilePath, out _); + } + + private static async Task ValidatePublicUrlAsync(string value, CancellationToken cancellationToken) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) || + string.IsNullOrWhiteSpace(uri.Host) || uri.UserInfo.Length > 0) + { + return "仅允许不含凭据的 HTTP(S) 公网地址。"; + } + + if (uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || uri.Host.EndsWith(".local", StringComparison.OrdinalIgnoreCase)) + { + return "不允许回环或局域网地址。"; + } + + return await ValidatePublicHostAsync(uri.DnsSafeHost, cancellationToken).ConfigureAwait(false); + } + + private static async Task ValidatePublicDomainAsync(string value, CancellationToken cancellationToken) + { + var domain = value.Trim().TrimEnd('.'); + if (domain.Length == 0 || domain.Contains('/') || domain.Contains('\\') || domain.Contains(':') || + domain.Contains('@') || domain.Contains('?') || domain.Contains('#') || + Uri.CheckHostName(domain) == UriHostNameType.Unknown) + { + return "请输入不含协议、端口、路径或凭据的公网域名。"; + } + + return await ValidatePublicHostAsync(domain, cancellationToken).ConfigureAwait(false); + } + + private static async Task ValidatePublicHostAsync(string host, CancellationToken cancellationToken) + { + if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || host.EndsWith(".local", StringComparison.OrdinalIgnoreCase)) + { + return "不允许回环或局域网地址。"; + } + + IPAddress[] addresses; + if (IPAddress.TryParse(host, out var literal)) + { + addresses = [literal]; + } + else + { + try + { + addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false); + } + catch (SocketException) + { + return "域名无法解析,未发送请求。"; + } + } + + return addresses.Length == 0 || addresses.Any(IsPrivateAddress) + ? "不允许回环、链路本地或私网地址。" + : null; + } + + private static bool IsPrivateAddress(IPAddress address) + { + if (IPAddress.IsLoopback(address) || address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any) || + address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || address.IsIPv6Multicast) + { + return true; + } + + if (address.AddressFamily == AddressFamily.InterNetworkV6 && !address.IsIPv4MappedToIPv6) + { + var ipv6Bytes = address.GetAddressBytes(); + return (ipv6Bytes[0] & 0xFE) == 0xFC; + } + + var bytes = address.AddressFamily == AddressFamily.InterNetworkV6 + ? address.MapToIPv4().GetAddressBytes() + : address.GetAddressBytes(); + return bytes[0] == 0 || bytes[0] == 10 || bytes[0] == 127 || + (bytes[0] == 169 && bytes[1] == 254) || + (bytes[0] == 172 && bytes[1] is >= 16 and <= 31) || + (bytes[0] == 192 && bytes[1] == 168) || + (bytes[0] >= 224); + } + + private static string? ValidateUpload(string path, out MediaTypeHeaderValue? mediaType) + { + mediaType = null; + if (!File.Exists(path)) + { + return "所选文件不存在。"; + } + + var info = new FileInfo(path); + Span header = stackalloc byte[16]; + using var stream = File.OpenRead(path); + var read = stream.Read(header); + var kind = DetectFileKind(header[..read]); + if (kind is null) + { + return "文件真实格式不在允许的图像、音频或视频类型中。"; + } + + var isImage = kind.StartsWith("image/", StringComparison.Ordinal); + var limit = isImage ? MaximumImageBytes : MaximumMediaBytes; + if (info.Length <= 0 || info.Length > limit) + { + return isImage ? "图片大小必须在 10 MiB 以内。" : "音视频大小必须在 50 MiB 以内。"; + } + + mediaType = new MediaTypeHeaderValue(kind); + return null; + } + + private static string? DetectFileKind(ReadOnlySpan value) + { + if (value.StartsWith(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A })) return "image/png"; + if (value.StartsWith(new byte[] { 0xFF, 0xD8, 0xFF })) return "image/jpeg"; + if (value.StartsWith("GIF87a"u8) || value.StartsWith("GIF89a"u8)) return "image/gif"; + if (value.Length >= 12 && value[..4].SequenceEqual("RIFF"u8) && value[8..12].SequenceEqual("WEBP"u8)) return "image/webp"; + if (value.StartsWith("ID3"u8) || (value.Length >= 2 && value[0] == 0xFF && (value[1] & 0xE0) == 0xE0)) return "audio/mpeg"; + if (value.Length >= 12 && value[..4].SequenceEqual("RIFF"u8) && value[8..12].SequenceEqual("WAVE"u8)) return "audio/wav"; + if (value.StartsWith("OggS"u8)) return "audio/ogg"; + if (value.Length >= 8 && value[4..8].SequenceEqual("ftyp"u8)) return "video/mp4"; + if (value.StartsWith(new byte[] { 0x1A, 0x45, 0xDF, 0xA3 })) return "video/webm"; + return null; + } + + private async Task BuildRequestAsync( + PearApiToolDefinition definition, + Uri endpoint, + IReadOnlyDictionary values, + string? filePath, + CancellationToken cancellationToken) + { + var populated = values + .Where(item => !PearApiPresentationSanitizer.IsHiddenField(item.Key) && !string.IsNullOrWhiteSpace(item.Value)) + .ToArray(); + if (definition.Method == PearApiRequestMethod.Get) + { + var builder = new UriBuilder(endpoint) + { + Query = string.Join("&", populated.Select(item => $"{Uri.EscapeDataString(item.Key)}={Uri.EscapeDataString(item.Value.Trim())}")) + }; + return CreateMessage(HttpMethod.Get, builder.Uri); + } + + var message = CreateMessage(HttpMethod.Post, endpoint); + if (definition.Method == PearApiRequestMethod.PostJson) + { + message.Content = new StringContent(JsonSerializer.Serialize(populated.ToDictionary(item => item.Key, item => item.Value.Trim())), Encoding.UTF8, "application/json"); + return message; + } + + var multipart = new MultipartFormDataContent(); + foreach (var item in populated) + { + multipart.Add(new StringContent(item.Value.Trim(), Encoding.UTF8), item.Key); + } + + if (!string.IsNullOrWhiteSpace(filePath)) + { + var error = ValidateUpload(filePath, out var mediaType); + if (error is not null || mediaType is null) + { + multipart.Dispose(); + message.Dispose(); + throw new InvalidDataException(error ?? "文件格式无效。"); + } + + var stream = File.OpenRead(filePath); + var file = new StreamContent(stream); + file.Headers.ContentType = mediaType; + multipart.Add(file, definition.Parameters.First(parameter => parameter.Kind == PearApiParameterKind.File).Name, Path.GetFileName(filePath)); + } + + message.Content = multipart; + await Task.CompletedTask.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return message; + } + + private static HttpRequestMessage CreateMessage(HttpMethod method, Uri uri) + { + var message = new HttpRequestMessage(method, uri); + message.Headers.Accept.ParseAdd("application/json, text/plain, image/*, audio/*;q=0.8, */*;q=0.5"); + return message; + } + + private async Task WaitForRateLimitAsync(CancellationToken cancellationToken) + { + await _requestGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var delay = MinimumRequestInterval - (DateTimeOffset.UtcNow - _lastRequestAt); + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + + _lastRequestAt = DateTimeOffset.UtcNow; + } + finally + { + _requestGate.Release(); + } + } + + private static string BuildCacheKey(PearApiToolDefinition definition, IReadOnlyDictionary values, string? filePath) + { + if (definition.PrivacyClass != PearApiPrivacyClass.Public || definition.Method != PearApiRequestMethod.Get || values.Any(item => !string.IsNullOrWhiteSpace(item.Value)) || !string.IsNullOrWhiteSpace(filePath)) + { + return $"nocache:{Guid.NewGuid():N}"; + } + + return definition.ToolId; + } + + private static async Task ReadBoundedContentAsync(HttpContent content, CancellationToken cancellationToken) + { + if (content.Headers.ContentLength is > MaximumResponseBytes) + { + throw new InvalidDataException("PearAPI response exceeded the size limit."); + } + + await using var input = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = new byte[16 * 1024]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + if (output.Length + read > MaximumResponseBytes) + { + throw new InvalidDataException("PearAPI response exceeded the size limit."); + } + output.Write(buffer, 0, read); + } + + return output.ToArray(); + } + + private static PearApiResponse Failure(string toolId, string message, TimeSpan? elapsed = null) + => new(toolId, false, 0, string.Empty, "text/plain", message, DateTimeOffset.UtcNow, elapsed ?? TimeSpan.Zero); + + private static string FriendlyStatus(HttpStatusCode statusCode) + => statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden + ? "PearAPI 已拒绝免 Key 请求,该接口当前可能要求鉴权。" + : $"PearAPI 返回 HTTP {(int)statusCode}。"; + + private Task WriteLogAsync(string level, string toolId, int statusCode, TimeSpan elapsed, CancellationToken cancellationToken) + => _logService?.WriteAsync(level, "pearapi", "PearAPI request completed", $"tool={toolId}; status={statusCode}; elapsedMs={elapsed.TotalMilliseconds:0}", cancellationToken) ?? Task.CompletedTask; + + private Task WriteFailureLogAsync(string toolId, string failureType, TimeSpan elapsed, CancellationToken cancellationToken) + => _logService?.WriteAsync("Warning", "pearapi", "PearAPI request failed", $"tool={toolId}; failure={failureType}; elapsedMs={elapsed.TotalMilliseconds:0}", cancellationToken) ?? Task.CompletedTask; + + private sealed record CacheEntry(DateTimeOffset StoredAt, PearApiResponse Response); +} diff --git a/src/YMhut.Box.Core/Api/PearApiModels.cs b/src/YMhut.Box.Core/Api/PearApiModels.cs new file mode 100644 index 0000000..28484de --- /dev/null +++ b/src/YMhut.Box.Core/Api/PearApiModels.cs @@ -0,0 +1,92 @@ +namespace YMhut.Box.Core.Api; + +public enum PearApiPrivacyClass +{ + Public, + UserText, + UserUrl, + Financial, + FileUpload +} + +public enum PearApiResultKind +{ + Text, + Json, + Cards, + Table, + RankedList, + News, + Image, + Audio +} + +public enum PearApiRequestMethod +{ + Get, + PostJson, + Multipart +} + +public enum PearApiParameterKind +{ + Text, + MultilineText, + Number, + Date, + Choice, + Url, + Domain, + File +} + +public sealed record PearApiParameterDefinition( + string Name, + string DisplayName, + string Description, + PearApiParameterKind Kind = PearApiParameterKind.Text, + bool Required = false, + string DefaultValue = "", + IReadOnlyList? Options = null, + int MaxLength = 4096); + +public sealed record PearApiToolDefinition( + string ToolId, + int ApiId, + string Name, + string Description, + string Category, + string EndpointPath, + PearApiRequestMethod Method, + IReadOnlyList Parameters, + PearApiPrivacyClass PrivacyClass, + PearApiResultKind ResultKind, + bool IsAvailableWithoutKey = true, + string AvailabilityNote = "", + TimeSpan? PublicCacheDuration = null) +{ + public bool HasUpload => Parameters.Any(parameter => parameter.Kind == PearApiParameterKind.File); +} + +public sealed record PearApiRequest( + string ToolId, + IReadOnlyDictionary? Parameters = null, + string? FilePath = null, + bool UploadConfirmed = false); + +public sealed record PearApiResponse( + string ToolId, + bool Success, + int StatusCode, + string Content, + string ContentType, + string? Error, + DateTimeOffset FetchedAt, + TimeSpan Elapsed, + bool IsFromCache = false, + byte[]? BinaryContent = null); + +public interface IPearApiClient +{ + Task SendAsync(PearApiRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/YMhut.Box.Core/Api/PearApiPresentationSanitizer.cs b/src/YMhut.Box.Core/Api/PearApiPresentationSanitizer.cs new file mode 100644 index 0000000..d79a97a --- /dev/null +++ b/src/YMhut.Box.Core/Api/PearApiPresentationSanitizer.cs @@ -0,0 +1,58 @@ +using System.Text; +using System.Text.Json; + +namespace YMhut.Box.Core.Api; + +public static class PearApiPresentationSanitizer +{ + public static bool IsHiddenField(string? name) + { + if (string.IsNullOrWhiteSpace(name)) return false; + var normalized = new string(name.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + return normalized == "apisource"; + } + + public static string SanitizeJson(string content) + { + if (string.IsNullOrWhiteSpace(content)) return content; + try + { + using var document = JsonDocument.Parse(content); + using var output = new MemoryStream(); + using (var writer = new Utf8JsonWriter(output)) + { + WriteSanitized(writer, document.RootElement); + } + return Encoding.UTF8.GetString(output.ToArray()); + } + catch (JsonException) + { + return content; + } + } + + private static void WriteSanitized(Utf8JsonWriter writer, JsonElement value) + { + switch (value.ValueKind) + { + case JsonValueKind.Object: + writer.WriteStartObject(); + foreach (var property in value.EnumerateObject()) + { + if (IsHiddenField(property.Name)) continue; + writer.WritePropertyName(property.Name); + WriteSanitized(writer, property.Value); + } + writer.WriteEndObject(); + break; + case JsonValueKind.Array: + writer.WriteStartArray(); + foreach (var item in value.EnumerateArray()) WriteSanitized(writer, item); + writer.WriteEndArray(); + break; + default: + value.WriteTo(writer); + break; + } + } +} diff --git a/src/YMhut.Box.Core/App/InstallManifest.cs b/src/YMhut.Box.Core/App/InstallManifest.cs index 6397ac6..1e62ff3 100644 --- a/src/YMhut.Box.Core/App/InstallManifest.cs +++ b/src/YMhut.Box.Core/App/InstallManifest.cs @@ -13,6 +13,8 @@ public sealed record InstallManifest( { public const string RelativePath = "config/install-manifest.ini"; + public int SchemaVersion { get; init; } = 1; + public static InstallManifest Parse(string text) { var release = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -69,7 +71,14 @@ public sealed record InstallManifest( GetReleaseValue(release, "PackageVersion")); } - return new InstallManifest(releaseInfo, requiredFiles, files); + var schemaVersion = release.TryGetValue("ManifestVersion", out var versionValue) && + int.TryParse(versionValue, out var parsedVersion) && parsedVersion > 0 + ? parsedVersion + : 1; + return new InstallManifest(releaseInfo, requiredFiles, files) + { + SchemaVersion = schemaVersion + }; } public static async Task ReadAsync(string installRoot, CancellationToken cancellationToken = default) diff --git a/src/YMhut.Box.Core/App/OpenSourceReferenceService.cs b/src/YMhut.Box.Core/App/OpenSourceReferenceService.cs index 1a646f2..fb395d3 100644 --- a/src/YMhut.Box.Core/App/OpenSourceReferenceService.cs +++ b/src/YMhut.Box.Core/App/OpenSourceReferenceService.cs @@ -53,6 +53,14 @@ public sealed class OpenSourceReferenceService(AppPaths paths, ILogService? logS }; AddMusicApiReferences(references); + references.Add(new OpenSourceReferenceItem( + "PearAPI", + "Online data provider", + "catalog verified 2026-08-19", + "Provides the fixed allowlisted, free endpoints used by the online tools. Runtime code never scrapes the documentation page and never sends an API key.", + "User text, URLs, financial queries, and selected files are sent to PearAPI only after the user runs the corresponding tool. Service availability and returned content are controlled by PearAPI.", + SourceUrl: "https://api.pearapi.ai/zh/dashboard/api-list", + LicensePath: "https://api.pearapi.ai/zh/disclaimer")); await AddPackageReferencesSafelyAsync(references, cancellationToken).ConfigureAwait(false); await AddBundledToolNoticesSafelyAsync(references, cancellationToken).ConfigureAwait(false); @@ -422,8 +430,9 @@ public sealed class OpenSourceReferenceService(AppPaths paths, ILogService? logS "Cache mechanism attribution" => 4, "Runtime" => 5, "Bundled npm dependencies" => 6, - "NuGet package" => 7, - "Third-party tool notice" => 8, + "Online data provider" => 7, + "NuGet package" => 8, + "Third-party tool notice" => 9, _ => 9 }; } diff --git a/src/YMhut.Box.Core/Data/AuthenticatedAssetPackage.cs b/src/YMhut.Box.Core/Data/AuthenticatedAssetPackage.cs new file mode 100644 index 0000000..751f61c --- /dev/null +++ b/src/YMhut.Box.Core/Data/AuthenticatedAssetPackage.cs @@ -0,0 +1,234 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace YMhut.Box.Core.Data; + +/// +/// Reads the authenticated package format used for bundled web and reference assets. +/// The key is intentionally an application-level packaging key, not DRM: a user with +/// process-level access can still inspect the decrypted bytes. Authentication is what +/// prevents an untrusted replacement from being accepted by the client. +/// +public static class AuthenticatedAssetPackage +{ + private static readonly byte[] Magic = Encoding.ASCII.GetBytes("YMHUTAS2"); + private const byte SchemaVersion = 2; + private const int SaltLength = 16; + private const int IvLength = 16; + private const int TagLength = 32; + private const int HeaderLength = 8 + 1 + SaltLength + IvLength + sizeof(long) + sizeof(long); + private const long MaximumPackageBytes = 512L * 1024 * 1024; + private const long MaximumEntryBytes = 64L * 1024 * 1024; + private const long MaximumTotalEntryBytes = 256L * 1024 * 1024; + private const int MaximumEntryCount = 4096; + + public sealed record PackageEntry(string Path, byte[] Content, string Sha256); + + public sealed record PackageManifest( + int SchemaVersion, + string BundleId, + IReadOnlyList Files); + + public sealed record PackageManifestEntry(string Path, long Length, string Sha256); + + public static IReadOnlyList ReadVerifiedEntries( + ReadOnlySpan package, + string purpose, + CancellationToken cancellationToken = default) + { + if (package.Length > MaximumPackageBytes) + { + throw new InvalidDataException("Authenticated asset package is too large."); + } + + var payload = Decrypt(package, purpose); + using var archive = new ZipArchive(new MemoryStream(payload, writable: false), ZipArchiveMode.Read); + var manifestEntries = archive.Entries + .Where(entry => string.Equals(entry.FullName, "_manifest.json", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (manifestEntries.Length != 1 || manifestEntries[0].Length is < 2 or > 2 * 1024 * 1024) + { + throw new InvalidDataException("Authenticated asset package manifest is missing or invalid."); + } + + var manifestEntry = manifestEntries[0]; + using var manifestStream = manifestEntry.Open(); + var manifest = JsonSerializer.Deserialize(manifestStream, JsonOptions) ?? + throw new InvalidDataException("Authenticated asset package manifest is invalid."); + if (manifest.SchemaVersion != SchemaVersion || + !string.Equals(manifest.BundleId, purpose, StringComparison.Ordinal)) + { + throw new InvalidDataException("Authenticated asset package schema is unsupported."); + } + + var declared = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in manifest.Files ?? []) + { + var normalized = NormalizeEntryPath(item.Path); + if (!declared.TryAdd(normalized, item) || item.Length < 0 || item.Length > MaximumEntryBytes || !IsSha256(item.Sha256)) + { + throw new InvalidDataException("Authenticated asset package manifest contains an invalid entry."); + } + } + + if (declared.Count == 0 || declared.Count > MaximumEntryCount) + { + throw new InvalidDataException("Authenticated asset package entry count is invalid."); + } + + var actual = archive.Entries + .Where(entry => !entry.FullName.EndsWith("/", StringComparison.Ordinal)) + .Where(entry => !string.Equals(entry.FullName, "_manifest.json", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (actual.Length != declared.Count) + { + throw new InvalidDataException("Authenticated asset package entry list does not match its manifest."); + } + + var result = new List(actual.Length); + long totalLength = 0; + foreach (var entry in actual) + { + cancellationToken.ThrowIfCancellationRequested(); + var normalized = NormalizeEntryPath(entry.FullName); + if (!declared.TryGetValue(normalized, out var expected)) + { + throw new InvalidDataException("Authenticated asset package contains an undeclared entry."); + } + + if (entry.Length < 0 || entry.Length > MaximumEntryBytes || totalLength > MaximumTotalEntryBytes - entry.Length) + { + throw new InvalidDataException("Authenticated asset package expanded size is invalid."); + } + + using var stream = entry.Open(); + using var memory = new MemoryStream(entry.Length > 0 && entry.Length <= int.MaxValue ? (int)entry.Length : 0); + stream.CopyTo(memory); + var content = memory.ToArray(); + if (content.LongLength != expected.Length) + { + throw new InvalidDataException($"Authenticated asset entry length mismatch: {normalized}"); + } + + var hash = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant(); + if (!CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(hash), + Encoding.ASCII.GetBytes(expected.Sha256.ToLowerInvariant()))) + { + throw new InvalidDataException($"Authenticated asset entry hash mismatch: {normalized}"); + } + + totalLength += content.LongLength; + result.Add(new PackageEntry(normalized, content, hash)); + } + + return result; + } + + public static ZipArchive OpenZipArchive(ReadOnlySpan package, string purpose) + { + var payload = Decrypt(package, purpose); + return new ZipArchive(new MemoryStream(payload, writable: false), ZipArchiveMode.Read, leaveOpen: false); + } + + public static string NormalizeEntryPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new InvalidDataException("Authenticated asset entry path is empty."); + } + + if (path.StartsWith('/') || path.StartsWith('\\') || Path.IsPathRooted(path)) + { + throw new InvalidDataException("Authenticated asset entry path must be relative."); + } + + var normalized = path.Replace('\\', '/'); + var segments = normalized.Split('/', StringSplitOptions.None); + if (normalized.Length > 240 || segments.Any(segment => + segment.Length == 0 || segment is "." or ".." || segment.Contains(':') || segment.Any(char.IsControl))) + { + throw new InvalidDataException("Authenticated asset entry path is unsafe."); + } + + return normalized; + } + + private static byte[] Decrypt(ReadOnlySpan package, string purpose) + { + if (package.Length < HeaderLength + TagLength || !package[..Magic.Length].SequenceEqual(Magic)) + { + throw new InvalidDataException("Authenticated asset package header is invalid."); + } + + var version = package[Magic.Length]; + if (version != SchemaVersion) + { + throw new InvalidDataException("Authenticated asset package version is unsupported."); + } + + var offset = Magic.Length + 1; + var salt = package.Slice(offset, SaltLength).ToArray(); + offset += SaltLength; + var iv = package.Slice(offset, IvLength).ToArray(); + offset += IvLength; + var plainLength = BinaryPrimitives.ReadInt64LittleEndian(package.Slice(offset, sizeof(long))); + offset += sizeof(long); + var cipherLength = BinaryPrimitives.ReadInt64LittleEndian(package.Slice(offset, sizeof(long))); + if (plainLength < 0 || plainLength > MaximumPackageBytes || cipherLength <= 0 || cipherLength > MaximumPackageBytes || + cipherLength > package.Length - HeaderLength - TagLength || HeaderLength + cipherLength + TagLength != package.Length) + { + throw new InvalidDataException("Authenticated asset package lengths are invalid."); + } + + var signedLength = checked(HeaderLength + (int)cipherLength); + var tag = package.Slice(signedLength, TagLength); + var keys = DeriveKeys(salt, purpose); + using var hmac = new HMACSHA256(keys.MacKey); + var calculatedTag = hmac.ComputeHash(package[..signedLength].ToArray()); + if (!CryptographicOperations.FixedTimeEquals(calculatedTag, tag)) + { + throw new InvalidDataException("Authenticated asset package signature is invalid."); + } + + try + { + using var aes = Aes.Create(); + aes.Key = keys.EncryptionKey; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + using var decryptor = aes.CreateDecryptor(); + var cipher = package.Slice(HeaderLength, (int)cipherLength).ToArray(); + var plain = decryptor.TransformFinalBlock(cipher, 0, cipher.Length); + if (plain.LongLength != plainLength) + { + throw new InvalidDataException("Authenticated asset package plaintext length is invalid."); + } + + return plain; + } + catch (CryptographicException exception) + { + throw new InvalidDataException("Authenticated asset package decryption failed.", exception); + } + } + + private static (byte[] EncryptionKey, byte[] MacKey) DeriveKeys(ReadOnlySpan salt, string purpose) + { + var purposeBytes = Encoding.UTF8.GetBytes("YMhut.Box.AssetPackage.v2|" + purpose); + var material = new byte[purposeBytes.Length + salt.Length]; + purposeBytes.CopyTo(material, 0); + salt.CopyTo(material.AsSpan(purposeBytes.Length)); + var digest = SHA512.HashData(material); + return (digest[..32], digest[32..]); + } + + private static bool IsSha256(string? value) + => !string.IsNullOrWhiteSpace(value) && value.Length == 64 && value.All(Uri.IsHexDigit); + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); +} diff --git a/src/YMhut.Box.Core/Data/ReferenceDataService.cs b/src/YMhut.Box.Core/Data/ReferenceDataService.cs index 80b6919..5d48610 100644 --- a/src/YMhut.Box.Core/Data/ReferenceDataService.cs +++ b/src/YMhut.Box.Core/Data/ReferenceDataService.cs @@ -43,6 +43,7 @@ public interface IReferenceDataService public sealed class ReferenceDataService(AppPaths paths) : IReferenceDataService { private static readonly byte[] YbinMagic = Encoding.ASCII.GetBytes("YMHUTYBIN1"); + private static readonly byte[] AuthenticatedYbinMagic = Encoding.ASCII.GetBytes("YMHUTAS2"); private static readonly byte[] YbinKey = SHA256.HashData(Encoding.UTF8.GetBytes("YMhut.Box.ReferenceData.YBin.v1")); public async Task ReadTextAsync(string relativePath, CancellationToken cancellationToken = default) @@ -326,6 +327,12 @@ public sealed class ReferenceDataService(AppPaths paths) : IReferenceDataService if (package.EndsWith(".ybin", StringComparison.OrdinalIgnoreCase)) { var encrypted = File.ReadAllBytes(package); + if (encrypted.Length >= AuthenticatedYbinMagic.Length && + encrypted.AsSpan(0, AuthenticatedYbinMagic.Length).SequenceEqual(AuthenticatedYbinMagic)) + { + return AuthenticatedAssetPackage.OpenZipArchive(encrypted, "reference-data"); + } + if (encrypted.Length <= YbinMagic.Length + 16 || !encrypted.AsSpan(0, YbinMagic.Length).SequenceEqual(YbinMagic)) { diff --git a/src/YMhut.Box.Core/Plugins/PluginBridgePolicy.cs b/src/YMhut.Box.Core/Plugins/PluginBridgePolicy.cs index 42c88f9..51353af 100644 --- a/src/YMhut.Box.Core/Plugins/PluginBridgePolicy.cs +++ b/src/YMhut.Box.Core/Plugins/PluginBridgePolicy.cs @@ -26,6 +26,7 @@ public static class PluginBridgePolicy ["clipboard.writeText"] = PluginPermission.Clipboard, ["file.openPicker"] = PluginPermission.FilePicker, ["file.savePicker"] = PluginPermission.FilePicker, + ["externalTool.launch"] = PluginPermission.ExternalTool, ["openExternal"] = PluginPermission.OpenExternal }; diff --git a/src/YMhut.Box.Core/Plugins/PluginModels.cs b/src/YMhut.Box.Core/Plugins/PluginModels.cs index 5e527d8..cd60072 100644 --- a/src/YMhut.Box.Core/Plugins/PluginModels.cs +++ b/src/YMhut.Box.Core/Plugins/PluginModels.cs @@ -25,7 +25,8 @@ public enum PluginPermission FileSystemWrite, EnvironmentRead, OpenSystemBrowser, - ExternalRuntime + ExternalRuntime, + ExternalTool } public enum PluginSurfaceKind @@ -69,7 +70,8 @@ public sealed record PluginManifest( PluginRequirementsSpec? Requirements = null, IReadOnlyDictionary? PermissionReasons = null, PluginNetworkSpec? Network = null, - bool BuiltIn = false) + bool BuiltIn = false, + IReadOnlyList? ToolBindings = null) { public static readonly string FileName = "ymhut.plugin.json"; @@ -115,7 +117,59 @@ public sealed record PluginTauriSpec( string TemplateVersion = "1", bool ShellPanel = true, bool BuildOnCreate = true, - bool RebuildOnMissingOrChanged = true); + bool RebuildOnMissingOrChanged = true, + string DistDirectory = "dist", + string HostApiVersion = "2"); + +public enum PluginToolArgumentKind +{ + Text, + Integer, + Boolean, + FilePath, + Choice +} + +public sealed record PluginToolFileSpec( + string Path, + string Sha256); + +public sealed record PluginToolArgumentSpec( + string Name, + PluginToolArgumentKind Kind = PluginToolArgumentKind.Text, + bool Required = false, + int MaxLength = 1024, + IReadOnlyList? Choices = null); + +public sealed record PluginToolBindingSpec( + string Id, + string Name, + string Executable, + IReadOnlyList Files, + IReadOnlyList? Arguments = null, + IReadOnlyList? Parameters = null, + string WorkingDirectory = ".", + string Architecture = "x64", + int TimeoutMs = 0); + +public sealed record PluginPackageManifest( + string Format, + int SchemaVersion, + string PluginId, + string PluginVersion, + string ManifestSha256, + DateTimeOffset CreatedAt, + IReadOnlyList? Files = null) +{ + public const string CurrentFormat = "ymplugin"; + + public const int CurrentSchemaVersion = 1; +} + +public sealed record PluginPackageFile( + string Path, + long Length, + string Sha256); public sealed record PluginCommandSpec( string Id, @@ -202,7 +256,13 @@ public static class PluginPermissionPolicy : [], RunToolIds = permission == PluginPermission.RunTool ? Normalize(manifest.Network?.RunToolIds) - : [] + : [], + ExternalRuntime = permission is PluginPermission.ExternalRuntime or PluginPermission.ExternalTool + ? manifest.Tauri + : null, + ToolBindings = permission is PluginPermission.ExternalRuntime or PluginPermission.ExternalTool + ? manifest.ToolBindings + : null }; var json = JsonSerializer.Serialize(policy); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json))); @@ -279,4 +339,9 @@ public static class PluginIds [JsonSerializable(typeof(PluginRequirementsSpec))] [JsonSerializable(typeof(PluginNetworkSpec))] [JsonSerializable(typeof(PluginTauriSpec))] +[JsonSerializable(typeof(PluginToolFileSpec))] +[JsonSerializable(typeof(PluginToolArgumentSpec))] +[JsonSerializable(typeof(PluginToolBindingSpec))] +[JsonSerializable(typeof(PluginPackageManifest))] +[JsonSerializable(typeof(PluginPackageFile))] internal sealed partial class PluginJsonContext : JsonSerializerContext; diff --git a/src/YMhut.Box.Core/Plugins/PluginPackageService.cs b/src/YMhut.Box.Core/Plugins/PluginPackageService.cs index 2b49f16..91572b3 100644 --- a/src/YMhut.Box.Core/Plugins/PluginPackageService.cs +++ b/src/YMhut.Box.Core/Plugins/PluginPackageService.cs @@ -1,14 +1,18 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.IO.Compression; +using System.Security.Cryptography; using YMhut.Box.Core.App; +using YMhut.Box.Core.Plugins.Runtime; namespace YMhut.Box.Core.Plugins; public enum PluginTemplateKind { ZeroPermissionWeb, - BridgeNetwork + BridgeNetwork, + TauriPrebuilt } public sealed record PluginPackageResult(bool Succeeded, string Message, string? PluginId = null, string? Path = null); @@ -19,16 +23,22 @@ public interface IPluginPackageService Task ImportFolderAsync(string sourceDirectory, string pluginsRoot, CancellationToken cancellationToken = default); + Task ImportPackageAsync(string packagePath, string pluginsRoot, CancellationToken cancellationToken = default); + + Task ExportPackageAsync(LoadedPlugin plugin, string packagePath, CancellationToken cancellationToken = default); + Task ClearDataAsync(LoadedPlugin plugin, CancellationToken cancellationToken = default); Task UninstallAsync(LoadedPlugin plugin, string pluginsRoot, CancellationToken cancellationToken = default); } -public sealed class PluginPackageService(AppPaths paths, IPluginStateStore stateStore) : IPluginPackageService +public sealed class PluginPackageService(AppPaths paths, IPluginStateStore stateStore) : IPluginPackageService, IPluginPackageGenerator { - private static readonly HashSet NativeExtensions = new(StringComparer.OrdinalIgnoreCase) + private const string PackageMetadataName = ".ymhut-package.json"; + private const long MaximumPackageBytes = 512L * 1024 * 1024; + private static readonly HashSet BlockedNativeExtensions = new(StringComparer.OrdinalIgnoreCase) { - ".exe", ".dll", ".com", ".scr", ".sys", ".msi", ".msix", ".msixbundle", ".appx", ".appxbundle" + ".com", ".scr", ".sys", ".msi", ".msix", ".msixbundle", ".appx", ".appxbundle" }; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { @@ -66,9 +76,11 @@ public sealed class PluginPackageService(AppPaths paths, IPluginStateStore state var manifest = CreateManifest(id, string.IsNullOrWhiteSpace(name) ? id : name.Trim(), kind); await File.WriteAllTextAsync(Path.Combine(target, PluginManifest.FileName), JsonSerializer.Serialize(manifest, JsonOptions), Encoding.UTF8, cancellationToken).ConfigureAwait(false); await File.WriteAllTextAsync(Path.Combine(target, "README.md"), TemplateReadme(manifest), Encoding.UTF8, cancellationToken).ConfigureAwait(false); - await File.WriteAllTextAsync(Path.Combine(target, "index.html"), TemplateHtml(manifest), Encoding.UTF8, cancellationToken).ConfigureAwait(false); - await File.WriteAllTextAsync(Path.Combine(target, "style.css"), TemplateCss, Encoding.UTF8, cancellationToken).ConfigureAwait(false); - await File.WriteAllTextAsync(Path.Combine(target, "main.js"), TemplateJavaScript(kind), Encoding.UTF8, cancellationToken).ConfigureAwait(false); + var contentRoot = kind == PluginTemplateKind.TauriPrebuilt ? Path.Combine(target, "dist") : target; + Directory.CreateDirectory(contentRoot); + await File.WriteAllTextAsync(Path.Combine(contentRoot, "index.html"), TemplateHtml(manifest), Encoding.UTF8, cancellationToken).ConfigureAwait(false); + await File.WriteAllTextAsync(Path.Combine(contentRoot, "style.css"), TemplateCss, Encoding.UTF8, cancellationToken).ConfigureAwait(false); + await File.WriteAllTextAsync(Path.Combine(contentRoot, "main.js"), TemplateJavaScript(kind), Encoding.UTF8, cancellationToken).ConfigureAwait(false); if (kind == PluginTemplateKind.ZeroPermissionWeb) { await File.WriteAllTextAsync(Path.Combine(target, "worker.js"), "self.onmessage = e => self.postMessage({ doubled: Number(e.data) * 2 });\n", Encoding.UTF8, cancellationToken).ConfigureAwait(false); @@ -97,38 +109,7 @@ public sealed class PluginPackageService(AppPaths paths, IPluginStateStore state try { await CopyDirectoryAsync(source, stagingRoot, cancellationToken).ConfigureAwait(false); - var manifestPath = Path.Combine(stagingRoot, PluginManifest.FileName); - if (!File.Exists(manifestPath)) - { - return new(false, "The selected folder has no ymhut.plugin.json."); - } - PluginManifest? manifest; - await using (var stream = File.OpenRead(manifestPath)) - { - manifest = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); - } - if (manifest is null || !PluginIds.IsSafeId(manifest.Id)) - { - return new(false, "The plugin manifest has an invalid ID."); - } - if (manifest.BuiltIn) - { - return new(false, "Imported packages cannot claim built-in status."); - } - var errors = PluginRegistryService.ValidatePackage(manifest, stagingRoot); - if (errors.Count > 0) - { - return new(false, $"Plugin validation failed: {string.Join("; ", errors.Take(4))}", manifest.Id); - } - - var target = Path.GetFullPath(Path.Combine(pluginsRoot, PluginIds.Normalize(manifest.Id))); - if (!PluginRegistryService.IsInside(pluginsRoot, target) || Directory.Exists(target)) - { - return new(false, "A plugin with the same ID already exists.", manifest.Id); - } - Directory.CreateDirectory(pluginsRoot); - Directory.Move(stagingRoot, target); - return new(true, "Plugin folder imported.", manifest.Id, target); + return await FinalizeImportAsync(stagingRoot, pluginsRoot, cancellationToken).ConfigureAwait(false); } finally { @@ -136,6 +117,175 @@ public sealed class PluginPackageService(AppPaths paths, IPluginStateStore state } } + public async Task ImportPackageAsync( + string packagePath, + string pluginsRoot, + CancellationToken cancellationToken = default) + { + if (!File.Exists(packagePath) || !string.Equals(Path.GetExtension(packagePath), ".ymplugin", StringComparison.OrdinalIgnoreCase)) + { + return new(false, "The selected file is not a .ymplugin package."); + } + + var stagingRoot = Path.Combine(paths.Cache, "PluginImports", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(stagingRoot); + try + { + using var archive = ZipFile.OpenRead(packagePath); + if (archive.Entries.Count > 5000 || archive.Entries.Sum(entry => entry.Length) > MaximumPackageBytes) + { + return new(false, "The plugin package exceeds the supported size or file-count limit."); + } + foreach (var entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + var relative = entry.FullName.Replace('/', Path.DirectorySeparatorChar); + var target = Path.GetFullPath(Path.Combine(stagingRoot, relative)); + if (string.IsNullOrWhiteSpace(relative) || !PluginRegistryService.IsInside(stagingRoot, target)) + { + return new(false, "The plugin package contains an unsafe path."); + } + if (entry.FullName.EndsWith("/", StringComparison.Ordinal)) + { + Directory.CreateDirectory(target); + continue; + } + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + await using var input = entry.Open(); + await using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous); + await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + } + + var metadataPath = Path.Combine(stagingRoot, PackageMetadataName); + if (!File.Exists(metadataPath)) + { + return new(false, "The .ymplugin package metadata is missing."); + } + var metadata = JsonSerializer.Deserialize(await File.ReadAllTextAsync(metadataPath, cancellationToken).ConfigureAwait(false), JsonOptions); + var manifestPath = Path.Combine(stagingRoot, PluginManifest.FileName); + if (metadata is null || metadata.Format != PluginPackageManifest.CurrentFormat || + metadata.SchemaVersion != PluginPackageManifest.CurrentSchemaVersion || !File.Exists(manifestPath) || + !string.Equals(metadata.ManifestSha256, await HashFileAsync(manifestPath, cancellationToken).ConfigureAwait(false), StringComparison.OrdinalIgnoreCase)) + { + return new(false, "The .ymplugin package metadata or manifest hash is invalid."); + } + var inventoryError = await ValidatePackageInventoryAsync(stagingRoot, metadata.Files, cancellationToken).ConfigureAwait(false); + if (inventoryError is not null) + { + return new(false, inventoryError); + } + var manifest = JsonSerializer.Deserialize(await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false), JsonOptions); + if (manifest is null || + !string.Equals(metadata.PluginId, manifest.Id, StringComparison.OrdinalIgnoreCase) || + !string.Equals(metadata.PluginVersion, manifest.Version, StringComparison.Ordinal)) + { + return new(false, "The .ymplugin package identity does not match its manifest."); + } + return await FinalizeImportAsync(stagingRoot, pluginsRoot, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is InvalidDataException or JsonException or IOException or UnauthorizedAccessException) + { + return new(false, "The .ymplugin archive is damaged or uses an unsupported format."); + } + finally + { + _ = TryDeleteDirectory(stagingRoot); + } + } + + public async Task ExportPackageAsync( + LoadedPlugin plugin, + string packagePath, + CancellationToken cancellationToken = default) + { + if (!plugin.IsValid || !Directory.Exists(plugin.RootPath)) + { + return new(false, "Only a valid installed plugin can be packaged.", plugin.Manifest.Id); + } + var errors = PluginRegistryService.ValidatePackage(plugin.Manifest, plugin.RootPath); + if (errors.Count > 0) + { + return new(false, $"Plugin validation failed: {string.Join("; ", errors.Take(4))}", plugin.Manifest.Id); + } + + packagePath = Path.ChangeExtension(Path.GetFullPath(packagePath), ".ymplugin"); + Directory.CreateDirectory(Path.GetDirectoryName(packagePath)!); + var temporary = packagePath + $".{Environment.ProcessId}.tmp"; + try + { + if (File.Exists(temporary)) File.Delete(temporary); + var sourceFiles = Directory.EnumerateFiles(plugin.RootPath, "*", SearchOption.AllDirectories) + .Where(file => !string.Equals(Path.GetFileName(file), PackageMetadataName, StringComparison.OrdinalIgnoreCase)) + .Select(file => new + { + FullPath = file, + RelativePath = Path.GetRelativePath(plugin.RootPath, file).Replace('\\', '/') + }) + .OrderBy(file => file.RelativePath, StringComparer.Ordinal) + .ToArray(); + var inventory = new List(sourceFiles.Length); + foreach (var file in sourceFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + inventory.Add(new PluginPackageFile( + file.RelativePath, + new FileInfo(file.FullPath).Length, + await HashFileAsync(file.FullPath, cancellationToken).ConfigureAwait(false))); + } + using (var archive = ZipFile.Open(temporary, ZipArchiveMode.Create)) + { + foreach (var file in sourceFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + var entry = archive.CreateEntry(file.RelativePath, CompressionLevel.SmallestSize); + await using var input = File.OpenRead(file.FullPath); + await using var output = entry.Open(); + await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + } + var manifestPath = Path.Combine(plugin.RootPath, PluginManifest.FileName); + var metadata = new PluginPackageManifest( + PluginPackageManifest.CurrentFormat, + PluginPackageManifest.CurrentSchemaVersion, + plugin.Manifest.Id, + plugin.Manifest.Version, + await HashFileAsync(manifestPath, cancellationToken).ConfigureAwait(false), + DateTimeOffset.UtcNow, + inventory); + var metadataEntry = archive.CreateEntry(PackageMetadataName, CompressionLevel.SmallestSize); + await using var writer = new StreamWriter(metadataEntry.Open(), new UTF8Encoding(false)); + await writer.WriteAsync(JsonSerializer.Serialize(metadata, JsonOptions)).ConfigureAwait(false); + } + File.Move(temporary, packagePath, true); + return new(true, "Plugin package created.", plugin.Manifest.Id, packagePath); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + public async Task CreateAsync( + PluginPackageCreateRequest request, + CancellationToken cancellationToken = default) + { + var pluginsRoot = PluginRegistryService.DefaultPluginsRoot(paths); + var result = await CreateTemplateAsync(pluginsRoot, request.Id, request.Name, PluginTemplateKind.TauriPrebuilt, cancellationToken).ConfigureAwait(false); + if (!result.Succeeded || string.IsNullOrWhiteSpace(result.Path)) + { + throw new InvalidOperationException(result.Message); + } + if (!string.IsNullOrWhiteSpace(request.SourceWebDirectory) && Directory.Exists(request.SourceWebDirectory)) + { + await CopyDirectoryAsync(request.SourceWebDirectory, Path.Combine(result.Path, "dist"), cancellationToken).ConfigureAwait(false); + } + var manifestPath = Path.Combine(result.Path, PluginManifest.FileName); + var manifest = JsonSerializer.Deserialize(await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false), JsonOptions) + ?? throw new InvalidDataException("The generated plugin manifest is invalid."); + var state = await stateStore.GetStateAsync(manifest.Id, cancellationToken).ConfigureAwait(false); + var plugin = new LoadedPlugin(manifest, result.Path, state, PluginRegistryService.ValidatePackage(manifest, result.Path)); + return new PluginPackageCreateResult(plugin, result.Path, manifestPath, null); + } + public async Task ClearDataAsync(LoadedPlugin plugin, CancellationToken cancellationToken = default) { await stateStore.ClearPluginDataAsync(plugin.Manifest.Id, includeState: false, cancellationToken).ConfigureAwait(false); @@ -168,6 +318,28 @@ public sealed class PluginPackageService(AppPaths paths, IPluginStateStore state private static PluginManifest CreateManifest(string id, string name, PluginTemplateKind kind) { + if (kind == PluginTemplateKind.TauriPrebuilt) + { + return new PluginManifest( + id, + name, + "1.0.0", + "Local developer", + "Prebuilt Tauri surface hosted by the bundled YMhut runtime.", + "dist/index.html", + [PluginPermission.ExternalRuntime], + [new PluginSurface(PluginSurfaceKind.ToolboxTool, "main", name, "Prebuilt Tauri plugin surface", "dist/index.html", "plugin")], + ["dist", "README.md"], + PluginRuntimeKind.Tauri, + Security: new PluginSecuritySpec([PluginPermission.ExternalRuntime]), + Tauri: new PluginTauriSpec(BuildOnCreate: false, RebuildOnMissingOrChanged: false), + ManifestVersion: PluginManifest.CurrentManifestVersion, + ApiVersion: "2", + PermissionReasons: new Dictionary + { + [PluginPermission.ExternalRuntime] = "Render the prebuilt plugin UI in the bundled controlled Tauri host." + }); + } var permissions = kind == PluginTemplateKind.BridgeNetwork ? new[] { PluginPermission.Http, PluginPermission.Log, PluginPermission.Output } : []; @@ -203,7 +375,9 @@ public sealed class PluginPackageService(AppPaths paths, IPluginStateStore state Network: network); } - private static string TemplateReadme(PluginManifest manifest) => $"# {manifest.Name}\n\nManifest v3 WebView sample. Local HTML/CSS/JS needs no client permission. All client and remote-service access must use declared scopes.\n"; + private static string TemplateReadme(PluginManifest manifest) => manifest.Runtime == PluginRuntimeKind.Tauri + ? $"# {manifest.Name}\n\nManifest v3 prebuilt Tauri sample. Put production-ready HTML/CSS/JS in dist/. YMhut Box supplies the fixed host; this package must not depend on Node, npm, Rust, or a private Tauri executable at runtime.\n" + : $"# {manifest.Name}\n\nManifest v3 WebView sample. Local HTML/CSS/JS needs no client permission. All client and remote-service access must use declared scopes.\n"; private static string TemplateHtml(PluginManifest manifest) => $""" @@ -234,7 +408,7 @@ public sealed class PluginPackageService(AppPaths paths, IPluginStateStore state status.textContent = `Wasm result: ${wasm.instance.exports.add(20, 22)}`; }); """ - : """ + : kind == PluginTemplateKind.BridgeNetwork ? """ const status = document.querySelector('#status'); document.querySelector('#run').addEventListener('click', async () => { const response = await window.ymhut.http.fetch({ url: 'https://api.ipify.org?format=json' }); @@ -243,6 +417,11 @@ public sealed class PluginPackageService(AppPaths paths, IPluginStateStore state await window.ymhut.output.set(status.textContent); await window.ymhut.log.info('Bridge/network sample completed'); }); + """ : """ + const status = document.querySelector('#status'); + document.querySelector('#run').addEventListener('click', async () => { + status.textContent = `Bundled Tauri host ready for ${window.__YMHT_RUNTIME__?.pluginId ?? 'plugin'}`; + }); """; private static bool HasUnsafePackageEntry(string root, out string? unsafeEntry) @@ -257,7 +436,7 @@ public sealed class PluginPackageService(AppPaths paths, IPluginStateStore state { var attributes = File.GetAttributes(entry); if ((attributes & FileAttributes.ReparsePoint) != 0 || - ((attributes & FileAttributes.Directory) == 0 && NativeExtensions.Contains(Path.GetExtension(entry)))) + ((attributes & FileAttributes.Directory) == 0 && BlockedNativeExtensions.Contains(Path.GetExtension(entry)))) { unsafeEntry = entry; return true; @@ -271,6 +450,99 @@ public sealed class PluginPackageService(AppPaths paths, IPluginStateStore state return false; } + private static async Task FinalizeImportAsync( + string stagingRoot, + string pluginsRoot, + CancellationToken cancellationToken) + { + var manifestPath = Path.Combine(stagingRoot, PluginManifest.FileName); + if (!File.Exists(manifestPath)) + { + return new(false, "The selected plugin has no ymhut.plugin.json."); + } + PluginManifest? manifest; + await using (var stream = File.OpenRead(manifestPath)) + { + manifest = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); + } + if (manifest is null || !PluginIds.IsSafeId(manifest.Id)) + { + return new(false, "The plugin manifest has an invalid ID."); + } + if (manifest.BuiltIn) + { + return new(false, "Imported packages cannot claim built-in status."); + } + var errors = PluginRegistryService.ValidatePackage(manifest, stagingRoot); + if (errors.Count > 0) + { + return new(false, $"Plugin validation failed: {string.Join("; ", errors.Take(4))}", manifest.Id); + } + var target = Path.GetFullPath(Path.Combine(pluginsRoot, PluginIds.Normalize(manifest.Id))); + if (!PluginRegistryService.IsInside(pluginsRoot, target) || Directory.Exists(target)) + { + return new(false, "A plugin with the same ID already exists.", manifest.Id); + } + Directory.CreateDirectory(pluginsRoot); + Directory.Move(stagingRoot, target); + return new(true, "Plugin imported.", manifest.Id, target); + } + + private static async Task HashFileAsync(string path, CancellationToken cancellationToken) + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); + return Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)); + } + + private static async Task ValidatePackageInventoryAsync( + string root, + IReadOnlyList? inventory, + CancellationToken cancellationToken) + { + if (inventory is null || inventory.Count == 0 || inventory.Count > 5000) + { + return "The .ymplugin package file inventory is missing or invalid."; + } + + var expected = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in inventory) + { + if (string.IsNullOrWhiteSpace(item.Path) || string.IsNullOrWhiteSpace(item.Sha256)) + { + return "The .ymplugin package file inventory contains an unsafe or duplicate entry."; + } + var relative = item.Path.Replace('/', Path.DirectorySeparatorChar); + var fullPath = Path.GetFullPath(Path.Combine(root, relative)); + if (string.IsNullOrWhiteSpace(item.Path) || item.Length < 0 || item.Length > MaximumPackageBytes || + item.Sha256.Length != 64 || !item.Sha256.All(Uri.IsHexDigit) || + !PluginRegistryService.IsInside(root, fullPath) || + string.Equals(Path.GetFileName(fullPath), PackageMetadataName, StringComparison.OrdinalIgnoreCase) || + !expected.TryAdd(Path.GetRelativePath(root, fullPath).Replace('\\', '/'), item)) + { + return "The .ymplugin package file inventory contains an unsafe or duplicate entry."; + } + } + + var actualFiles = Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .Where(file => !string.Equals(Path.GetFileName(file), PackageMetadataName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (actualFiles.Length != expected.Count) + { + return "The .ymplugin package file inventory does not match the archive."; + } + foreach (var file in actualFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + var relative = Path.GetRelativePath(root, file).Replace('\\', '/'); + if (!expected.TryGetValue(relative, out var item) || new FileInfo(file).Length != item.Length || + !string.Equals(await HashFileAsync(file, cancellationToken).ConfigureAwait(false), item.Sha256, StringComparison.OrdinalIgnoreCase)) + { + return $"The .ymplugin package file failed integrity validation: {relative}"; + } + } + return null; + } + private static async Task CopyDirectoryAsync(string source, string target, CancellationToken cancellationToken) { Directory.CreateDirectory(target); diff --git a/src/YMhut.Box.Core/Plugins/PluginRegistryService.cs b/src/YMhut.Box.Core/Plugins/PluginRegistryService.cs index a932a95..0160d46 100644 --- a/src/YMhut.Box.Core/Plugins/PluginRegistryService.cs +++ b/src/YMhut.Box.Core/Plugins/PluginRegistryService.cs @@ -1,6 +1,9 @@ using System.Text.Json; using System.Text.Json.Serialization; +using System.Text; using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.RegularExpressions; using YMhut.Box.Core.App; using YMhut.Box.Core.Logging; using YMhut.Box.Core.Settings; @@ -333,6 +336,223 @@ public sealed class PluginRegistryService( errors.Add("Plugins that declare commands must request ShellExecute or ScriptExecute permission."); } } + + ValidateTauriPackage(manifest, root, declaredPermissions, requiredPermissions, errors, strict); + } + + private static void ValidateTauriPackage( + PluginManifest manifest, + string root, + ISet declaredPermissions, + IReadOnlyList requiredPermissions, + IList errors, + bool strict) + { + var bindings = manifest.ToolBindings ?? []; + if (bindings.Count > 0 && manifest.Runtime != PluginRuntimeKind.Tauri) + { + errors.Add("Native tool bindings are only available to controlled Tauri plugins."); + } + if (bindings.Count > 0 && + (!declaredPermissions.Contains(PluginPermission.ExternalTool) || + !requiredPermissions.Contains(PluginPermission.ExternalTool))) + { + errors.Add("Plugins with native tool bindings must declare ExternalTool as a required permission."); + } + + if (manifest.Runtime == PluginRuntimeKind.Tauri && manifest.Tauri is { } tauri) + { + if (!string.IsNullOrWhiteSpace(tauri.Executable)) + { + errors.Add("Tauri plugins use the bundled YMhut host and cannot declare a private host executable."); + } + if (strict && string.IsNullOrWhiteSpace(tauri.HostApiVersion)) + { + errors.Add("Tauri hostApiVersion is required."); + } + if (!IsSafeRelativePath(root, tauri.DistDirectory)) + { + errors.Add("Tauri distDirectory must exist inside the plugin directory."); + } + } + + var bindingIds = new HashSet(StringComparer.OrdinalIgnoreCase); + var declaredNativeFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var binding in bindings) + { + if (!PluginIds.IsSafeId(binding.Id) || !bindingIds.Add(binding.Id)) + { + errors.Add($"Native tool binding id is invalid or duplicated: {binding.Id}"); + } + if (string.IsNullOrWhiteSpace(binding.Name)) + { + errors.Add($"Native tool binding name is required: {binding.Id}"); + } + if (!IsSafeRelativeFile(root, binding.Executable) || + !string.Equals(Path.GetExtension(binding.Executable), ".exe", StringComparison.OrdinalIgnoreCase)) + { + errors.Add($"Native tool executable must be a declared EXE inside the plugin directory: {binding.Id}"); + } + else + { + var executable = Path.GetFullPath(Path.Combine(root, binding.Executable)); + if (IsBlockedNativeToolName(executable)) + { + errors.Add($"Installers, updaters, services, drivers, and uninstallers cannot be bound as plugin tools: {binding.Id}"); + } + if (HasElevatedExecutionManifest(executable)) + { + errors.Add($"Native plugin tools cannot request elevation: {binding.Id}"); + } + } + if (!IsSafeRelativePath(root, binding.WorkingDirectory)) + { + errors.Add($"Native tool working directory escapes the plugin directory: {binding.Id}"); + } + if (binding.Architecture.Trim().ToLowerInvariant() is not ("x64" or "x64compatible")) + { + errors.Add($"Native tool architecture is not supported: {binding.Id}"); + } + if (binding.TimeoutMs < 0 || binding.TimeoutMs > 86_400_000) + { + errors.Add($"Native tool timeout is outside the supported range: {binding.Id}"); + } + + var parameterNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var parameter in binding.Parameters ?? []) + { + if (!PluginIds.IsSafeId(parameter.Name) || !parameterNames.Add(parameter.Name) || + parameter.MaxLength is < 1 or > 32_768) + { + errors.Add($"Native tool parameter is invalid: {binding.Id}/{parameter.Name}"); + } + if (parameter.Kind == PluginToolArgumentKind.Choice && (parameter.Choices?.Count ?? 0) == 0) + { + errors.Add($"Native tool choice parameter has no allowed values: {binding.Id}/{parameter.Name}"); + } + } + foreach (var template in binding.Arguments ?? []) + { + if (template.Length > 4096 || template.Contains('\0') || template.Contains('\r') || template.Contains('\n')) + { + errors.Add($"Native tool argument template is invalid: {binding.Id}"); + continue; + } + foreach (Match match in Regex.Matches(template, "\\{(?[A-Za-z0-9_.-]+)\\}", RegexOptions.CultureInvariant)) + { + if (!parameterNames.Contains(match.Groups["name"].Value)) + { + errors.Add($"Native tool argument references an undeclared parameter: {binding.Id}/{match.Groups["name"].Value}"); + } + } + } + + var bindingFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var file in binding.Files ?? []) + { + var extension = Path.GetExtension(file.Path); + var allowedNativeExtension = extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".dll", StringComparison.OrdinalIgnoreCase); + if (!bindingFiles.Add(file.Path) || !IsSafeRelativeFile(root, file.Path) || + !allowedNativeExtension) + { + errors.Add($"Native tool file is invalid or duplicated: {binding.Id}/{file.Path}"); + continue; + } + if (!IsSha256(file.Sha256) || !FileHashMatches(root, file)) + { + errors.Add($"Native tool file hash does not match: {binding.Id}/{file.Path}"); + continue; + } + declaredNativeFiles.Add(Path.GetFullPath(Path.Combine(root, file.Path))); + } + var executablePath = Path.GetFullPath(Path.Combine(root, binding.Executable)); + if (!declaredNativeFiles.Contains(executablePath)) + { + errors.Add($"Native tool executable is missing from the hashed file inventory: {binding.Id}"); + } + } + + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + var extension = Path.GetExtension(file); + if (extension.Equals(".com", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".scr", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".sys", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".msi", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".msix", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".msixbundle", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".appx", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".appxbundle", StringComparison.OrdinalIgnoreCase)) + { + errors.Add($"Blocked native package file: {Path.GetRelativePath(root, file)}"); + } + else if ((extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) || extension.Equals(".dll", StringComparison.OrdinalIgnoreCase)) && + !declaredNativeFiles.Contains(Path.GetFullPath(file))) + { + errors.Add($"Undeclared native package file: {Path.GetRelativePath(root, file)}"); + } + } + } + + private static bool IsSha256(string value) => + value.Length == 64 && value.All(Uri.IsHexDigit); + + private static bool IsBlockedNativeToolName(string path) + { + var name = Path.GetFileNameWithoutExtension(path); + return new[] { "setup", "installer", "install", "uninstall", "updater", "update", "service", "driver" } + .Any(token => name.Contains(token, StringComparison.OrdinalIgnoreCase)); + } + + private static bool HasElevatedExecutionManifest(string path) + { + try + { + var patterns = new[] + { + Encoding.UTF8.GetBytes("requireAdministrator"), + Encoding.UTF8.GetBytes("highestAvailable"), + Encoding.Unicode.GetBytes("requireAdministrator"), + Encoding.Unicode.GetBytes("highestAvailable") + }; + var overlap = patterns.Max(pattern => pattern.Length) - 1; + var buffer = new byte[64 * 1024 + overlap]; + var carried = 0; + using var stream = File.OpenRead(path); + while (true) + { + var read = stream.Read(buffer, carried, buffer.Length - carried); + if (read == 0) + { + return patterns.Any(pattern => buffer.AsSpan(0, carried).IndexOf(pattern) >= 0); + } + var length = carried + read; + if (patterns.Any(pattern => buffer.AsSpan(0, length).IndexOf(pattern) >= 0)) + { + return true; + } + carried = Math.Min(overlap, length); + buffer.AsSpan(length - carried, carried).CopyTo(buffer); + } + } + catch + { + return true; + } + } + + private static bool FileHashMatches(string root, PluginToolFileSpec file) + { + try + { + using var stream = File.OpenRead(Path.GetFullPath(Path.Combine(root, file.Path))); + return string.Equals(Convert.ToHexString(SHA256.HashData(stream)), file.Sha256, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } } public static bool IsSafeRelativeFile(string root, string relativePath) diff --git a/src/YMhut.Box.Core/Plugins/Runtime/PluginRuntimeDependencyModels.cs b/src/YMhut.Box.Core/Plugins/Runtime/PluginRuntimeDependencyModels.cs new file mode 100644 index 0000000..adbae43 --- /dev/null +++ b/src/YMhut.Box.Core/Plugins/Runtime/PluginRuntimeDependencyModels.cs @@ -0,0 +1,15 @@ +namespace YMhut.Box.Core.Plugins.Runtime; + +public sealed record PluginRuntimeDependencyInfo( + bool IsReady, + string HostExecutable, + string RuntimeManifest, + string TemplatePackage, + string HostApiVersion, + string TemplateVersion, + string? Diagnostic = null); + +public interface IPluginRuntimeDependencyService +{ + Task GetAsync(CancellationToken cancellationToken = default); +} diff --git a/src/YMhut.Box.Core/Settings/AgreementDocument.cs b/src/YMhut.Box.Core/Settings/AgreementDocument.cs index 84cb43b..64b0bac 100644 --- a/src/YMhut.Box.Core/Settings/AgreementDocument.cs +++ b/src/YMhut.Box.Core/Settings/AgreementDocument.cs @@ -13,7 +13,13 @@ public static class AgreementDocument public const string MusicProviderDisclaimerVersion = "2.1"; public const int MusicProviderDisclaimerRevision = 3; - public const string CurrentVersion = "2.0.6.2"; + + public const string PearApiNoticeVersion = "1.0"; + + public const string PluginNoticeVersion = "1.0"; + // Keep the agreement version aligned with the shipped release so users who + // accepted an older document are asked to review the expanded terms again. + public const string CurrentVersion = "2.0.7.14"; public const int CurrentRevision = 1; diff --git a/src/YMhut.Box.Core/Settings/AppSettings.cs b/src/YMhut.Box.Core/Settings/AppSettings.cs index 90537fe..c409871 100644 --- a/src/YMhut.Box.Core/Settings/AppSettings.cs +++ b/src/YMhut.Box.Core/Settings/AppSettings.cs @@ -47,6 +47,10 @@ public sealed class AppSettings public string UserAgreementVersion { get; set; } = string.Empty; + public string PearApiNoticeVersion { get; set; } = string.Empty; + + public string PluginNoticeVersion { get; set; } = string.Empty; + public DateTimeOffset? UserAgreementAcceptedAt { get; set; } public bool SidebarCollapsed { get; set; } diff --git a/src/YMhut.Box.Core/Tools/ToolCatalog.cs b/src/YMhut.Box.Core/Tools/ToolCatalog.cs index 57269a1..e599d6d 100644 --- a/src/YMhut.Box.Core/Tools/ToolCatalog.cs +++ b/src/YMhut.Box.Core/Tools/ToolCatalog.cs @@ -1,3 +1,5 @@ +using YMhut.Box.Core.Api; + namespace YMhut.Box.Core.Tools; public sealed class ToolCatalog @@ -6,7 +8,8 @@ public sealed class ToolCatalog { "hardware", "optimization", - "music" + "music", + "online_tools" }; private readonly List _modules; @@ -46,10 +49,14 @@ public sealed class ToolCatalog public static IEnumerable DefaultModules() { - return SortForDisplay(RawToolData + var builtIn = RawToolData .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(ParseModule) - .Concat([DevEnvironmentConfigModule()])); + .Concat([DevEnvironmentConfigModule()]) + .ToList(); + var ids = builtIn.Select(module => module.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + builtIn.AddRange(PearApiCatalog.CreateToolModules(ids)); + return SortForDisplay(builtIn); } public static IOrderedEnumerable SortForDisplay( @@ -93,6 +100,25 @@ public sealed class ToolCatalog ? parsedAddedOrder : 0; + if (PearApiCatalog.TryGet(id, out var pearDefinition)) + { + return new ToolModule(new ToolMetadata( + id, + name, + description, + category, + keywords, + offline, + IconForCategory(category), + addedOrder, + PearApiCatalog.ProviderName, + new ToolOnlineCapabilityInfo( + PearApiCatalog.ProviderName, + pearDefinition.PrivacyClass.ToString(), + pearDefinition.PrivacyClass != PearApiPrivacyClass.Public, + pearDefinition.IsAvailableWithoutKey))); + } + return new ToolModule(new ToolMetadata(id, name, description, category, keywords, offline, IconForCategory(category), addedOrder)); } @@ -157,6 +183,7 @@ public sealed class ToolCatalog hardware 硬件与系统状态 实时传感器、静态硬件清单、提供方状态与报告导出。 system true 硬件;系统;状态;传感器;CPU;GPU 300 optimization 优化中心 预览系统变更、按需提权、执行日志和可用回滚。 system true 优化;系统;回滚;清理;网络 290 music 网络音乐播放器 搜索、歌单、播放队列、歌词与系统媒体控制。 network false 音乐;播放器;歌单;歌词;媒体 280 +online_tools 联网工具 安全访问 PearAPI 的免费免 Key 接口,并按用途分类浏览。 network false 联网;PearAPI;在线;API;数据 275 html_js_playground HTML/JS 渲染器 运行 HTML、CSS、JS、引用脚本并查看输出日志 dev false html;js;webview;plugin;playground json_formatter JSON 格式化 美化、压缩并校验 JSON 内容 dev true json;format;pretty;minify base64_codec Base64 编解码 在 Base64 与 UTF-8 文本之间转换 dev true base64;encode;decode diff --git a/src/YMhut.Box.Core/Tools/ToolMetadata.cs b/src/YMhut.Box.Core/Tools/ToolMetadata.cs index 4fc59f0..88a6c33 100644 --- a/src/YMhut.Box.Core/Tools/ToolMetadata.cs +++ b/src/YMhut.Box.Core/Tools/ToolMetadata.cs @@ -1,5 +1,11 @@ namespace YMhut.Box.Core.Tools; +public sealed record ToolOnlineCapabilityInfo( + string Provider, + string PrivacyClass, + bool SendsUserInput, + bool AvailableWithoutAuthentication); + public sealed record ToolMetadata( string Id, string Name, @@ -8,4 +14,6 @@ public sealed record ToolMetadata( IReadOnlyList Keywords, bool OfflineCapable, string IconGlyph, - int AddedOrder = 0); + int AddedOrder = 0, + string? Provider = null, + ToolOnlineCapabilityInfo? OnlineCapability = null); diff --git a/src/YMhut.Box.DownloadHost/YMhut.Box.DownloadHost.csproj b/src/YMhut.Box.DownloadHost/YMhut.Box.DownloadHost.csproj index 1b26d00..704301a 100644 --- a/src/YMhut.Box.DownloadHost/YMhut.Box.DownloadHost.csproj +++ b/src/YMhut.Box.DownloadHost/YMhut.Box.DownloadHost.csproj @@ -6,6 +6,7 @@ enable YMhut.Box.DownloadHost YMhut.Box.DownloadHost + ..\box-winUI\Assets\app_icon.ico none diff --git a/src/YMhut.Box.HardwareMonitor/YMhut.Box.HardwareMonitor.csproj b/src/YMhut.Box.HardwareMonitor/YMhut.Box.HardwareMonitor.csproj index 3b7292f..9d6ce45 100644 --- a/src/YMhut.Box.HardwareMonitor/YMhut.Box.HardwareMonitor.csproj +++ b/src/YMhut.Box.HardwareMonitor/YMhut.Box.HardwareMonitor.csproj @@ -8,6 +8,7 @@ enable YMhut.Box.HardwareMonitor YMhut.Box.HardwareMonitor + ..\box-winUI\Assets\app_icon.ico none diff --git a/src/YMhut.Box.InstallerBootstrap/InstallerApp.cs b/src/YMhut.Box.InstallerBootstrap/InstallerApp.cs index 44ae7b8..92a7aa2 100644 --- a/src/YMhut.Box.InstallerBootstrap/InstallerApp.cs +++ b/src/YMhut.Box.InstallerBootstrap/InstallerApp.cs @@ -20,6 +20,13 @@ public sealed partial class InstallerApp : Application protected override void OnLaunched(LaunchActivatedEventArgs args) { + var mode = InstallerInvocation.Resolve(_arguments); + if (mode == InstallerInvocationMode.Uninstall) + { + _window = new UninstallerWindow(UninstallOptions.Parse(_arguments)); + _window.Activate(); + return; + } _window = new InstallerWindow(InstallerOptions.Parse(_arguments)); _window.Activate(); if (_arguments.Any(argument => argument.Equals("/WINDOWSELFTEST", StringComparison.OrdinalIgnoreCase))) diff --git a/src/YMhut.Box.InstallerBootstrap/Program.cs b/src/YMhut.Box.InstallerBootstrap/Program.cs index ebd77ab..8451c70 100644 --- a/src/YMhut.Box.InstallerBootstrap/Program.cs +++ b/src/YMhut.Box.InstallerBootstrap/Program.cs @@ -22,14 +22,19 @@ public static class Program eventArgs.SetObserved(); }; Environment.SetEnvironmentVariable("MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", AppContext.BaseDirectory); + var invocationMode = InstallerInvocation.Resolve(args); if (args.Any(argument => argument.Equals("/SELFTEST", StringComparison.OrdinalIgnoreCase))) { - Environment.Exit(InstallerEngine.SelfTest() ? 0 : 3); + Environment.Exit(invocationMode == InstallerInvocationMode.Uninstall + ? UninstallEngine.SelfTest(UninstallOptions.Parse(args)) ? 0 : 3 + : InstallerEngine.SelfTest() ? 0 : 3); return; } if (InstallerOptions.IsAutomation(args)) { - Environment.Exit(InstallerEngine.RunForwardedAsync(args).GetAwaiter().GetResult()); + Environment.Exit(invocationMode == InstallerInvocationMode.Uninstall + ? UninstallEngine.RunAsync(UninstallOptions.Parse(args)).GetAwaiter().GetResult().ExitCode + : InstallerEngine.RunForwardedAsync(args).GetAwaiter().GetResult()); return; } RunApp(args); diff --git a/src/YMhut.Box.InstallerBootstrap/UninstallEngine.cs b/src/YMhut.Box.InstallerBootstrap/UninstallEngine.cs new file mode 100644 index 0000000..fc582f3 --- /dev/null +++ b/src/YMhut.Box.InstallerBootstrap/UninstallEngine.cs @@ -0,0 +1,214 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace YMhut.Box.InstallerBootstrap; + +public static class UninstallEngine +{ + public static bool SelfTest(UninstallOptions options) => ResolveEngine(options.InstallDirectory) is not null; + + public static async Task RunAsync( + UninstallOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + var engine = ResolveEngine(options.InstallDirectory); + if (engine is null) + { + return new(false, 3, "未找到内部卸载引擎,请重新安装当前版本后再卸载。"); + } + var logRoot = Path.Combine(Path.GetTempPath(), "YMhutBoxSetup"); + Directory.CreateDirectory(logRoot); + var logPath = Path.Combine(logRoot, $"uninstall-{DateTime.Now:yyyyMMdd-HHmmss}.log"); + var arguments = new List + { + "/VERYSILENT", + "/SUPPRESSMSGBOXES", + "/NORESTART", + $"/LOG={logPath}" + }; + progress?.Report("正在关闭组件并清理程序文件…"); + try + { + var startInfo = new ProcessStartInfo + { + FileName = engine, + WorkingDirectory = Path.GetDirectoryName(engine)!, + UseShellExecute = false, + CreateNoWindow = true + }; + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + using var process = Process.Start(startInfo); + if (process is null) return new(false, 4, "无法启动内部卸载引擎。", logPath); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + if (process.ExitCode != 0) + { + return new(false, process.ExitCode, $"卸载引擎退出代码 {process.ExitCode}。", logPath); + } + if (options.RemoveUserData) + { + progress?.Report("正在删除用户明确选择清理的数据…"); + DeleteUserData(options.InstallDirectory); + } + ScheduleBootstrapCleanup(options.InstallDirectory); + return new(true, 0, options.RemoveUserData + ? "程序文件和本地用户数据已完成清理。" + : "程序文件已完成清理,设置、登录态、插件数据和日志已保留。", logPath); + } + catch (OperationCanceledException) + { + return new(false, 1223, "卸载已取消。", logPath); + } + catch (Exception exception) + { + return new(false, 5, $"卸载未完成:{exception.Message}", logPath); + } + } + + private static string? ResolveEngine(string installDirectory) + { + var candidate = Path.GetFullPath(Path.Combine(installDirectory, "Runtime", "uninstall-engine", "unins000.exe")); + var data = Path.ChangeExtension(candidate, ".dat"); + return File.Exists(candidate) && File.Exists(data) && + !string.Equals(candidate, Environment.ProcessPath, StringComparison.OrdinalIgnoreCase) + ? candidate + : null; + } + + private static void DeleteUserData(string installDirectory) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var defaultRoot = Path.Combine(localAppData, "YMhut Box", "WinUI"); + var roots = new HashSet(StringComparer.OrdinalIgnoreCase) + { + defaultRoot, + Path.Combine(appData, "YMhut Box"), + Path.Combine(localAppData, "ymhut_box"), + Path.Combine(appData, "ymhut_box") + }; + + // A custom plugin root is user data only when it stays inside an app-owned + // data directory. Never allow a tampered settings file to turn uninstall + // into deletion of an arbitrary user or system directory. + var customPluginRoot = ReadConfiguredPluginRoot(Path.Combine(defaultRoot, "settings.json")); + if (customPluginRoot is not null && IsSafeCustomPluginRoot(customPluginRoot, localAppData, appData)) + { + roots.Add(customPluginRoot); + } + + foreach (var root in roots.OrderByDescending(value => value.Length)) + { + if (!IsSafeUserDataRoot(root, installDirectory) && + !IsSafeCustomPluginRoot(root, localAppData, appData)) + { + continue; + } + + DeleteDirectory(root); + } + } + + private static string? ReadConfiguredPluginRoot(string settingsPath) + { + try + { + if (!File.Exists(settingsPath)) return null; + using var document = JsonDocument.Parse(File.ReadAllText(settingsPath)); + if (!document.RootElement.TryGetProperty("pluginRootPath", out var value) && + !document.RootElement.TryGetProperty("PluginRootPath", out value)) + { + return null; + } + + var configured = value.GetString(); + return string.IsNullOrWhiteSpace(configured) + ? null + : Path.GetFullPath(Environment.ExpandEnvironmentVariables(configured.Trim())); + } + catch + { + return null; + } + } + + private static bool IsSafeCustomPluginRoot(string path, string localAppData, string appData) + { + var fullPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var allowedParents = new[] + { + Path.Combine(localAppData, "YMhut Box"), + Path.Combine(appData, "YMhut Box"), + Path.Combine(localAppData, "ymhut_box"), + Path.Combine(appData, "ymhut_box") + }; + + return allowedParents.Any(parent => IsChildPath(parent, fullPath)); + } + + private static bool IsSafeUserDataRoot(string path, string installDirectory) + { + try + { + var fullPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var installRoot = Path.GetFullPath(installDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return !string.Equals(fullPath, installRoot, StringComparison.OrdinalIgnoreCase) && + !string.Equals(fullPath, Path.GetPathRoot(fullPath)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), StringComparison.OrdinalIgnoreCase) && + (fullPath.EndsWith("YMhut Box\\WinUI", StringComparison.OrdinalIgnoreCase) || + fullPath.EndsWith("YMhut Box", StringComparison.OrdinalIgnoreCase) || + fullPath.EndsWith("ymhut_box", StringComparison.OrdinalIgnoreCase)); + } + catch + { + return false; + } + } + + private static bool IsChildPath(string parent, string child) + { + var parentFull = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var childFull = Path.GetFullPath(child).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return childFull.StartsWith(parentFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + childFull.StartsWith(parentFull + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + private static void DeleteDirectory(string root) + { + if (!Directory.Exists(root)) return; + foreach (var path in Directory.EnumerateFileSystemEntries(root, "*", SearchOption.AllDirectories).OrderByDescending(value => value.Length)) + { + try { File.SetAttributes(path, FileAttributes.Normal); } catch { } + } + + try { File.SetAttributes(root, FileAttributes.Normal); } catch { } + try { Directory.Delete(root, recursive: true); } catch { } + + var parent = Path.GetDirectoryName(root); + if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) + { + try { Directory.Delete(parent); } catch { } + } + } + + private static void ScheduleBootstrapCleanup(string installDirectory) + { + var bootstrap = Path.Combine(installDirectory, "unins000.exe"); + var command = $"ping 127.0.0.1 -n 2 >nul & del /f /q \"{bootstrap}\""; + try + { + _ = Process.Start(new ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", + Arguments = "/d /c " + command, + WorkingDirectory = installDirectory, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden + }); + } + catch + { + // The engine has already completed. Leaving the entry in place is preferable to failing the uninstall. + } + } +} diff --git a/src/YMhut.Box.InstallerBootstrap/UninstallModels.cs b/src/YMhut.Box.InstallerBootstrap/UninstallModels.cs new file mode 100644 index 0000000..0ca8fb2 --- /dev/null +++ b/src/YMhut.Box.InstallerBootstrap/UninstallModels.cs @@ -0,0 +1,53 @@ +namespace YMhut.Box.InstallerBootstrap; + +public enum InstallerInvocationMode +{ + Install, + Uninstall +} + +public sealed record UninstallOptions( + string InstallDirectory, + bool RemoveUserData, + bool Silent, + IReadOnlyList OriginalArguments) +{ + public static UninstallOptions Parse(IReadOnlyList arguments) + { + var directoryArgument = arguments.FirstOrDefault(argument => argument.StartsWith("/INSTALLDIR=", StringComparison.OrdinalIgnoreCase)); + var directory = directoryArgument is null + ? InstallerEngine.DetectExistingInstall() ?? AppContext.BaseDirectory + : directoryArgument[12..].Trim('"'); + return new UninstallOptions( + Path.GetFullPath(directory), + arguments.Any(argument => argument.Equals("/REMOVEUSERDATA", StringComparison.OrdinalIgnoreCase)), + InstallerOptions.IsAutomation(arguments), + arguments); + } +} + +public sealed record UninstallResult( + bool Success, + int ExitCode, + string Message, + string? LogPath = null); + +public static class InstallerInvocation +{ + public static InstallerInvocationMode Resolve(IReadOnlyList arguments) + { +#if UNINSTALL_BOOTSTRAP + return InstallerInvocationMode.Uninstall; +#else + if (arguments.Any(argument => argument.Equals("/UNINSTALL", StringComparison.OrdinalIgnoreCase))) + { + return InstallerInvocationMode.Uninstall; + } + var processName = Path.GetFileNameWithoutExtension(Environment.ProcessPath); + return string.Equals(processName, "unins000", StringComparison.OrdinalIgnoreCase) && + Directory.Exists(Path.Combine(AppContext.BaseDirectory, "Runtime", "uninstall-engine")) + ? InstallerInvocationMode.Uninstall + : InstallerInvocationMode.Install; +#endif + } +} diff --git a/src/YMhut.Box.InstallerBootstrap/UninstallerWindow.cs b/src/YMhut.Box.InstallerBootstrap/UninstallerWindow.cs new file mode 100644 index 0000000..c0932e5 --- /dev/null +++ b/src/YMhut.Box.InstallerBootstrap/UninstallerWindow.cs @@ -0,0 +1,233 @@ +using Microsoft.UI; +using Microsoft.UI.Text; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.Graphics; +using WinRT.Interop; + +namespace YMhut.Box.InstallerBootstrap; + +public sealed class UninstallerWindow : Window +{ + private static readonly SolidColorBrush Background = Brush("#FFF3F1EC"); + private static readonly SolidColorBrush Surface = Brush("#FFFCFBF8"); + private static readonly SolidColorBrush Stroke = Brush("#FFD6D2CB"); + private static readonly SolidColorBrush Primary = Brush("#FF343330"); + private static readonly SolidColorBrush Secondary = Brush("#FF716E68"); + private static readonly SolidColorBrush Accent = Brush("#FF55514C"); + private static readonly SolidColorBrush Danger = Brush("#FFB42318"); + private readonly Border _dragArea = new(); + private readonly CheckBox _removeData = new() + { + Content = "同时删除设置、登录态、插件数据、缓存和日志", + IsChecked = false + }; + private readonly TextBlock _status = Text("默认保留全部用户数据。", 13, Secondary, 5); + private readonly ProgressRing _progress = new() { Width = 22, Height = 22, Visibility = Visibility.Collapsed }; + private readonly Button _uninstallButton; + private readonly Button _cancelButton; + private readonly UninstallOptions _options; + private bool _running; + private AppWindow? _appWindow; + + public UninstallerWindow(UninstallOptions options) + { + _options = options; + Title = "YMhut Box 卸载程序"; + _uninstallButton = CommandButton("卸载", "\uE74D", BeginUninstallAsync, true); + _cancelButton = CommandButton("取消", "\uE711", Close); + Content = BuildContent(); + ExtendsContentIntoTitleBar = true; + SetTitleBar(_dragArea); + Activated += (_, _) => ConfigureWindow(); + } + + private UIElement BuildContent() + { + var root = new Grid { Background = Background, RequestedTheme = ElementTheme.Light }; + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(48) }); + root.RowDefinitions.Add(new RowDefinition()); + _dragArea.Background = Surface; + _dragArea.BorderBrush = Stroke; + _dragArea.BorderThickness = new Thickness(0, 0, 0, 1); + _dragArea.Padding = new Thickness(18, 0, 148, 0); + _dragArea.Child = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 9, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + new FontIcon { Glyph = "\uE74D", FontSize = 16, Foreground = Accent }, + Text("YMhut Box 卸载程序", 13, Primary, 1, FontWeights.SemiBold) + } + }; + root.Children.Add(_dragArea); + + var content = new StackPanel + { + Spacing = 18, + MaxWidth = 680, + HorizontalAlignment = HorizontalAlignment.Stretch, + Children = + { + Text("卸载 YMhut Box", 28, Primary, 2, FontWeights.SemiBold), + Text($"将从以下位置移除程序文件:\n{_options.InstallDirectory}", 13, Secondary, 4), + new InfoBar + { + IsOpen = true, + IsClosable = false, + Severity = InfoBarSeverity.Informational, + Title = "用户数据默认保留", + Message = "设置、登录态、插件数据、日志和下载记录不会随程序文件一起删除,重新安装后仍可继续使用。" + }, + new Border + { + Padding = new Thickness(16), + Background = Surface, + BorderBrush = Stroke, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(7), + Child = new StackPanel + { + Spacing = 8, + Children = + { + Text("数据清理", 16, Primary, 1, FontWeights.SemiBold), + _removeData, + Text("勾选后不可恢复。仅在确认不再需要本机配置和插件内容时使用。", 12, Danger, 3) + } + } + }, + new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 10, + Children = { _progress, _status } + } + } + }; + var actions = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Right, + Children = { _cancelButton, _uninstallButton } + }; + var layout = new Grid { Padding = new Thickness(36, 32, 36, 24) }; + layout.RowDefinitions.Add(new RowDefinition()); + layout.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + layout.Children.Add(new ScrollViewer + { + Content = content, + VerticalScrollMode = ScrollMode.Enabled, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled + }); + Grid.SetRow(actions, 1); + layout.Children.Add(actions); + Grid.SetRow(layout, 1); + root.Children.Add(layout); + return root; + } + + private async void BeginUninstallAsync() + { + if (_running) return; + _running = true; + _uninstallButton.IsEnabled = false; + _cancelButton.IsEnabled = false; + _removeData.IsEnabled = false; + _progress.Visibility = Visibility.Visible; + _progress.IsActive = true; + var options = _options with { RemoveUserData = _removeData.IsChecked == true }; + var progress = new Progress(value => _status.Text = value); + var result = await UninstallEngine.RunAsync(options, progress); + _progress.IsActive = false; + _progress.Visibility = Visibility.Collapsed; + _status.Text = result.Message; + _status.Foreground = result.Success ? Accent : Danger; + _cancelButton.Content = result.Success ? "关闭" : "返回"; + _cancelButton.IsEnabled = true; + if (result.Success) + { + _running = false; + } + if (!result.Success) + { + _uninstallButton.IsEnabled = true; + _removeData.IsEnabled = true; + _running = false; + } + } + + private void ConfigureWindow() + { + if (_appWindow is not null) return; + SystemBackdrop = null; + var hwnd = WindowNative.GetWindowHandle(this); + _appWindow = AppWindow.GetFromWindowId(Win32Interop.GetWindowIdFromWindow(hwnd)); + _appWindow.Resize(new SizeInt32(780, 570)); + if (AppWindowTitleBar.IsCustomizationSupported()) + { + _appWindow.TitleBar.ExtendsContentIntoTitleBar = true; + _appWindow.TitleBar.BackgroundColor = ColorHelper.FromArgb(255, 252, 251, 248); + _appWindow.TitleBar.ButtonBackgroundColor = ColorHelper.FromArgb(255, 252, 251, 248); + _appWindow.TitleBar.ButtonForegroundColor = ColorHelper.FromArgb(255, 52, 51, 48); + } + if (_appWindow.Presenter is OverlappedPresenter presenter) + { + presenter.IsResizable = true; + presenter.IsMaximizable = false; + presenter.IsMinimizable = true; + } + _appWindow.Closing += (_, args) => args.Cancel = _running; + } + + private static Button CommandButton(string label, string glyph, Action action, bool primary = false) + { + var button = new Button + { + MinWidth = 112, + Height = 38, + Padding = new Thickness(14, 0, 14, 0), + CornerRadius = new CornerRadius(6), + Background = primary ? Accent : Surface, + Foreground = primary ? new SolidColorBrush(Microsoft.UI.Colors.White) : Primary, + BorderBrush = primary ? Accent : Stroke, + BorderThickness = new Thickness(1), + Content = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 7, + Children = + { + new FontIcon { Glyph = glyph, FontSize = 14 }, + Text(label, 13, primary ? new SolidColorBrush(Microsoft.UI.Colors.White) : Primary, 1, FontWeights.SemiBold) + } + } + }; + AutomationProperties.SetName(button, label); + button.Click += (_, _) => action(); + return button; + } + + private static TextBlock Text(string value, double size, Brush foreground, int maxLines, Windows.UI.Text.FontWeight? weight = null) => new() + { + Text = value, + FontSize = size, + FontWeight = weight ?? FontWeights.Normal, + Foreground = foreground, + TextWrapping = TextWrapping.Wrap, + MaxLines = maxLines + }; + + private static SolidColorBrush Brush(string color) => new(Windows.UI.Color.FromArgb( + Convert.ToByte(color[1..3], 16), + Convert.ToByte(color[3..5], 16), + Convert.ToByte(color[5..7], 16), + Convert.ToByte(color[7..9], 16))); +} diff --git a/src/YMhut.Box.InstallerBootstrap/YMhut.Box.InstallerBootstrap.csproj b/src/YMhut.Box.InstallerBootstrap/YMhut.Box.InstallerBootstrap.csproj index f6d8818..0528259 100644 --- a/src/YMhut.Box.InstallerBootstrap/YMhut.Box.InstallerBootstrap.csproj +++ b/src/YMhut.Box.InstallerBootstrap/YMhut.Box.InstallerBootstrap.csproj @@ -23,6 +23,10 @@ ..\box-winUI\Assets\app_icon.ico app.manifest + + $(DefineConstants);UNINSTALL_BOOTSTRAP + unins000 + diff --git a/src/YMhut.Box.PluginHost/Program.cs b/src/YMhut.Box.PluginHost/Program.cs index 75eed6b..be9ba20 100644 --- a/src/YMhut.Box.PluginHost/Program.cs +++ b/src/YMhut.Box.PluginHost/Program.cs @@ -436,6 +436,7 @@ async Task DispatchBridgeAsync(LoadedPlugin plugin, string "clipboard.writeText" => AuthorizeUi(plugin, PluginPermission.Clipboard, "clipboard.writeText"), "file.openPicker" => AuthorizeUi(plugin, PluginPermission.FilePicker, "file.openPicker"), "file.savePicker" => AuthorizeUi(plugin, PluginPermission.FilePicker, "file.savePicker"), + "externalTool.launch" => AuthorizeUi(plugin, PluginPermission.ExternalTool, "externalTool.launch"), "openExternal" => ValidateExternal(plugin, payload), _ => Fail($"Unknown plugin bridge method: {method}") }; diff --git a/src/YMhut.Box.PluginHost/YMhut.Box.PluginHost.csproj b/src/YMhut.Box.PluginHost/YMhut.Box.PluginHost.csproj index f8ea68a..ad114bd 100644 --- a/src/YMhut.Box.PluginHost/YMhut.Box.PluginHost.csproj +++ b/src/YMhut.Box.PluginHost/YMhut.Box.PluginHost.csproj @@ -4,6 +4,7 @@ net10.0 enable enable + ..\box-winUI\Assets\app_icon.ico none diff --git a/src/YMhut.Box.PluginTauriHost/LICENSES.txt b/src/YMhut.Box.PluginTauriHost/LICENSES.txt new file mode 100644 index 0000000..b19ff15 --- /dev/null +++ b/src/YMhut.Box.PluginTauriHost/LICENSES.txt @@ -0,0 +1,10 @@ +YMhut Box controlled Tauri plugin host + +The host uses Tauri 2 and its transitive Rust dependencies. Tauri is available +under the Apache License 2.0 or MIT License. Complete dependency versions are +locked by src-tauri/Cargo.lock and the corresponding source remains in this +repository. See THIRD_PARTY_NOTICES.md in the application directory for the +project-wide notices and license links. + +Tauri: https://github.com/tauri-apps/tauri +License: Apache-2.0 OR MIT diff --git a/src/YMhut.Box.PluginTauriHost/runtime.json b/src/YMhut.Box.PluginTauriHost/runtime.json new file mode 100644 index 0000000..a0166eb --- /dev/null +++ b/src/YMhut.Box.PluginTauriHost/runtime.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "hostApiVersion": "2", + "templateVersion": "1", + "runtime": "YMhut.Box.PluginTauriHost", + "pluginBuildMode": "prebuilt", + "requiresSystemNode": false, + "requiresSystemRust": false +} diff --git a/src/YMhut.Box.PluginTauriHost/src-tauri/icons/icon.ico b/src/YMhut.Box.PluginTauriHost/src-tauri/icons/icon.ico index 08918ba..ab342b9 100644 Binary files a/src/YMhut.Box.PluginTauriHost/src-tauri/icons/icon.ico and b/src/YMhut.Box.PluginTauriHost/src-tauri/icons/icon.ico differ diff --git a/src/YMhut.Box.PluginTauriHost/src-tauri/src/main.rs b/src/YMhut.Box.PluginTauriHost/src-tauri/src/main.rs index b068c6b..4439a45 100644 --- a/src/YMhut.Box.PluginTauriHost/src-tauri/src/main.rs +++ b/src/YMhut.Box.PluginTauriHost/src-tauri/src/main.rs @@ -712,6 +712,7 @@ const BRIDGE_BOOTSTRAP: &str = r#" clipboard: { readText: () => call("clipboard.readText"), writeText: text => call("clipboard.writeText", text) }, file: { openPicker: () => call("file.openPicker"), savePicker: (name, value) => call("file.savePicker", { name, value }) }, tool: { run: (toolId, input) => call("tool.run", { toolId, input }) }, + externalTool: { launch: (bindingId, args = {}) => call("externalTool.launch", { bindingId, arguments: args }) }, openExternal: (url, options) => call("openExternal", { url, options }) }; Object.defineProperty(window, "ymhut", { value: Object.freeze(api), writable: false, configurable: false }); diff --git a/src/YMhut.Box.PluginTauriHost/template.ymtemplate b/src/YMhut.Box.PluginTauriHost/template.ymtemplate new file mode 100644 index 0000000..2547fea --- /dev/null +++ b/src/YMhut.Box.PluginTauriHost/template.ymtemplate @@ -0,0 +1,17 @@ +{ + "format": "ymhut-tauri-template", + "schemaVersion": 1, + "templateVersion": "1", + "hostApiVersion": "2", + "buildMode": "prebuilt", + "files": [ + { + "path": "dist/index.html", + "source": "template/dist/index.html" + }, + { + "path": "README.md", + "source": "template/README.md" + } + ] +} diff --git a/src/YMhut.Box.PluginTauriHost/template/README.md b/src/YMhut.Box.PluginTauriHost/template/README.md new file mode 100644 index 0000000..f14eeb0 --- /dev/null +++ b/src/YMhut.Box.PluginTauriHost/template/README.md @@ -0,0 +1,3 @@ +# YMhut Box prebuilt Tauri plugin + +Place production-ready HTML, CSS and JavaScript in `dist/`. The client supplies the controlled Tauri host. The installed plugin must not invoke Node, npm, Cargo or a private Tauri executable. diff --git a/src/YMhut.Box.PrivilegedHost/YMhut.Box.PrivilegedHost.csproj b/src/YMhut.Box.PrivilegedHost/YMhut.Box.PrivilegedHost.csproj index db906e5..bb77c4e 100644 --- a/src/YMhut.Box.PrivilegedHost/YMhut.Box.PrivilegedHost.csproj +++ b/src/YMhut.Box.PrivilegedHost/YMhut.Box.PrivilegedHost.csproj @@ -8,6 +8,7 @@ enable YMhut.Box.PrivilegedHost YMhut.Box.PrivilegedHost + ..\box-winUI\Assets\app_icon.ico diff --git a/src/YMhut.Box.Tests/ApplicationIconTests.cs b/src/YMhut.Box.Tests/ApplicationIconTests.cs new file mode 100644 index 0000000..20fac24 --- /dev/null +++ b/src/YMhut.Box.Tests/ApplicationIconTests.cs @@ -0,0 +1,140 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Xml.Linq; + +namespace YMhut.Box.Tests; + +[TestClass] +public sealed class ApplicationIconTests +{ + private static readonly int[] ExpectedFrameSizes = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256]; + + [TestMethod] + public void SharedApplicationIconsContainEveryDpiFrameAndMatchExactly() + { + var root = FindRepositoryRoot(); + var paths = new[] + { + Path.Combine(root, "assets", "icon.ico"), + Path.Combine(root, "assets", "icons", "app_icon.ico"), + Path.Combine(root, "src", "box-winUI", "Assets", "app_icon.ico"), + Path.Combine(root, "src", "YMhut.Box.PluginTauriHost", "src-tauri", "icons", "icon.ico") + }; + var hashes = paths.Select(path => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))).ToArray(); + + Assert.IsTrue(hashes.All(hash => hash == hashes[0])); + foreach (var path in paths) + { + CollectionAssert.AreEqual(ExpectedFrameSizes, ReadIcoFrameSizes(path)); + } + } + + [TestMethod] + public void MsixLogosHaveStableNativeDimensions() + { + var assets = Path.Combine(FindRepositoryRoot(), "src", "box-winUI", "Assets"); + var expected = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["StoreLogo.png"] = (50, 50), + ["Square44x44Logo.png"] = (44, 44), + ["Square150x150Logo.png"] = (150, 150), + ["LockScreenLogo.png"] = (70, 70), + ["Wide310x150Logo.png"] = (310, 150) + }; + + foreach (var item in expected) + { + Assert.AreEqual(item.Value, ReadPngDimensions(Path.Combine(assets, item.Key)), item.Key); + } + } + + [TestMethod] + public void EveryOwnedUserVisibleExecutableUsesTheSharedIcon() + { + var root = FindRepositoryRoot(); + var projects = new[] + { + "box-winUI/YMhut.Box.WinUI.csproj", + "YMhut.Box.InstallerBootstrap/YMhut.Box.InstallerBootstrap.csproj", + "YMhut.Box.DownloadHost/YMhut.Box.DownloadHost.csproj", + "YMhut.Box.HardwareMonitor/YMhut.Box.HardwareMonitor.csproj", + "YMhut.Box.PluginHost/YMhut.Box.PluginHost.csproj", + "YMhut.Box.PrivilegedHost/YMhut.Box.PrivilegedHost.csproj", + "YMhut.Box.Worker/YMhut.Box.Worker.csproj" + }; + + foreach (var relativePath in projects) + { + var document = XDocument.Load(Path.Combine(root, "src", relativePath.Replace('/', Path.DirectorySeparatorChar))); + var applicationIcon = document.Descendants().FirstOrDefault(element => element.Name.LocalName == "ApplicationIcon"); + Assert.IsNotNull(applicationIcon, relativePath); + StringAssert.EndsWith(applicationIcon.Value.Replace('/', '\\'), "app_icon.ico", relativePath); + } + } + + [TestMethod] + public void WindowIconServiceUsesEmbeddedHandlesWithoutTemporaryFiles() + { + var path = Path.Combine(FindRepositoryRoot(), "src", "box-winUI", "Services", "WindowIconService.cs"); + var source = File.ReadAllText(path); + + StringAssert.Contains(source, "GetManifestResourceStream"); + StringAssert.Contains(source, "WmSetIcon"); + StringAssert.Contains(source, "ConditionalWeakTable"); + Assert.IsFalse(source.Contains("GetTempPath", StringComparison.Ordinal)); + Assert.IsFalse(source.Contains("Delete(", StringComparison.Ordinal)); + } + + private static int[] ReadIcoFrameSizes(string path) + { + var bytes = File.ReadAllBytes(path); + Assert.IsGreaterThanOrEqualTo(6, bytes.Length, path); + Assert.AreEqual((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(bytes)); + Assert.AreEqual((ushort)1, BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(2))); + var count = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(4)); + Assert.AreEqual(ExpectedFrameSizes.Length, count, path); + + var sizes = new int[count]; + for (var index = 0; index < count; index++) + { + var offset = 6 + index * 16; + Assert.IsGreaterThanOrEqualTo(offset + 16, bytes.Length, path); + var width = bytes[offset] == 0 ? 256 : bytes[offset]; + var height = bytes[offset + 1] == 0 ? 256 : bytes[offset + 1]; + Assert.AreEqual(width, height, path); + var payloadLength = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset + 8)); + var payloadOffset = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset + 12)); + Assert.IsTrue(payloadLength > 8 && payloadOffset + payloadLength <= bytes.Length, path); + Assert.IsTrue(bytes.AsSpan((int)payloadOffset, 8).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }), path); + sizes[index] = width; + } + + return sizes; + } + + private static (int Width, int Height) ReadPngDimensions(string path) + { + var bytes = File.ReadAllBytes(path); + Assert.IsGreaterThanOrEqualTo(24, bytes.Length, path); + Assert.IsTrue(bytes.AsSpan(0, 8).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }), path); + return ( + BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(16, 4)), + BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(20, 4))); + } + + private static string FindRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "YMhut.Box.Native.sln"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Repository root was not found."); + } +} diff --git a/src/YMhut.Box.Tests/AuthenticatedAssetPackageTests.cs b/src/YMhut.Box.Tests/AuthenticatedAssetPackageTests.cs new file mode 100644 index 0000000..4ac0069 --- /dev/null +++ b/src/YMhut.Box.Tests/AuthenticatedAssetPackageTests.cs @@ -0,0 +1,143 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using YMhut.Box.Core.App; +using YMhut.Box.Core.Data; + +namespace YMhut.Box.Tests; + +[TestClass] +public sealed class AuthenticatedAssetPackageTests +{ + [TestMethod] + public void ReadVerifiedEntriesAcceptsAuthenticatedContent() + { + var package = CreatePackage("runtime-assets", ("tool-pages/index.html", "ok")); + + var entries = AuthenticatedAssetPackage.ReadVerifiedEntries(package, "runtime-assets"); + + Assert.HasCount(1, entries); + Assert.AreEqual("tool-pages/index.html", entries[0].Path); + Assert.AreEqual("ok", Encoding.UTF8.GetString(entries[0].Content)); + } + + [TestMethod] + public void ReadVerifiedEntriesRejectsTampering() + { + var package = CreatePackage("runtime-assets", ("tool-pages/index.html", "ok")); + package[^40] ^= 0x5a; + + Assert.ThrowsExactly(() => + AuthenticatedAssetPackage.ReadVerifiedEntries(package, "runtime-assets")); + } + + [TestMethod] + public void ReadVerifiedEntriesRejectsPathTraversal() + { + var package = CreatePackage("runtime-assets", ("../replaced.png", "not-an-image")); + + Assert.ThrowsExactly(() => + AuthenticatedAssetPackage.ReadVerifiedEntries(package, "runtime-assets")); + } + + [TestMethod] + public void ReadVerifiedEntriesRejectsAbsolutePath() + { + var package = CreatePackage("runtime-assets", ("/replaced.png", "not-an-image")); + + Assert.ThrowsExactly(() => + AuthenticatedAssetPackage.ReadVerifiedEntries(package, "runtime-assets")); + } + + [TestMethod] + public async Task ReferenceDataServiceReadsAuthenticatedYbin() + { + var root = Path.Combine(Path.GetTempPath(), "ymhut-box-auth-assets", Guid.NewGuid().ToString("N")); + var assets = Path.Combine(root, "Assets"); + Directory.CreateDirectory(Path.Combine(assets, "data")); + try + { + var package = CreatePackage("reference-data", ("data/reference/sample.json", "{\"value\":42}")); + await File.WriteAllBytesAsync(Path.Combine(assets, "data", "ymhut-data.ybin"), package); + var service = new ReferenceDataService(AppPaths.ForCurrentUser(Path.Combine(root, "User"), assets)); + + var text = await service.ReadTextAsync("data/reference/sample.json"); + + Assert.AreEqual("{\"value\":42}", text); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + private static byte[] CreatePackage(string purpose, params (string Path, string Text)[] files) + { + var content = files.Select(file => (file.Path, Bytes: Encoding.UTF8.GetBytes(file.Text))).ToArray(); + using var plainStream = new MemoryStream(); + using (var archive = new ZipArchive(plainStream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var file in content) + { + var entry = archive.CreateEntry(file.Path, CompressionLevel.Fastest); + using var output = entry.Open(); + output.Write(file.Bytes); + } + + var manifest = new + { + schemaVersion = 2, + bundleId = purpose, + files = content.Select(file => new + { + path = file.Path, + length = file.Bytes.Length, + sha256 = Convert.ToHexString(SHA256.HashData(file.Bytes)).ToLowerInvariant() + }) + }; + var manifestEntry = archive.CreateEntry("_manifest.json", CompressionLevel.Fastest); + using var manifestOutput = manifestEntry.Open(); + JsonSerializer.Serialize(manifestOutput, manifest); + } + + var plain = plainStream.ToArray(); + var salt = RandomNumberGenerator.GetBytes(16); + var iv = RandomNumberGenerator.GetBytes(16); + var purposeBytes = Encoding.UTF8.GetBytes("YMhut.Box.AssetPackage.v2|" + purpose); + var material = new byte[purposeBytes.Length + salt.Length]; + purposeBytes.CopyTo(material, 0); + salt.CopyTo(material, purposeBytes.Length); + var keys = SHA512.HashData(material); + + byte[] cipher; + using (var aes = Aes.Create()) + { + aes.Key = keys[..32]; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + using var encryptor = aes.CreateEncryptor(); + cipher = encryptor.TransformFinalBlock(plain, 0, plain.Length); + } + + using var signedStream = new MemoryStream(); + using (var writer = new BinaryWriter(signedStream, Encoding.UTF8, leaveOpen: true)) + { + writer.Write(Encoding.ASCII.GetBytes("YMHUTAS2")); + writer.Write((byte)2); + writer.Write(salt); + writer.Write(iv); + writer.Write((long)plain.Length); + writer.Write((long)cipher.Length); + writer.Write(cipher); + } + + var signed = signedStream.ToArray(); + using var hmac = new HMACSHA256(keys[32..]); + return [.. signed, .. hmac.ComputeHash(signed)]; + } +} diff --git a/src/YMhut.Box.Tests/InstallLayoutPathsTests.cs b/src/YMhut.Box.Tests/InstallLayoutPathsTests.cs index 6fd174b..6ab0410 100644 --- a/src/YMhut.Box.Tests/InstallLayoutPathsTests.cs +++ b/src/YMhut.Box.Tests/InstallLayoutPathsTests.cs @@ -115,6 +115,88 @@ public sealed class InstallLayoutPathsTests Assert.IsTrue(manifest.RequiredFiles.SequenceEqual(["YMhutBox.exe", "YMhutBox.dll"])); Assert.IsTrue(manifest.ContainsFile(@"lang\zh-CN\Microsoft.ui.xaml.dll.mui")); Assert.IsTrue(manifest.ContainsFile("Tools/SampleTool/tool.exe")); + Assert.AreEqual(1, manifest.SchemaVersion); + } + + [TestMethod] + public void InstallManifestParserReadsVersionedManifestAndKeepsLegacyCompatibility() + { + var versioned = InstallManifest.Parse( + """ + [Release] + ManifestVersion=2 + Version=2.0.7.14 + + [Files] + YMhutBox.exe + """); + var legacy = InstallManifest.Parse("[Files]\nYMhutBox.exe"); + + Assert.AreEqual(2, versioned.SchemaVersion); + Assert.AreEqual(1, legacy.SchemaVersion); + Assert.IsTrue(versioned.ContainsFile("YMhutBox.exe")); + Assert.IsTrue(legacy.ContainsFile("YMhutBox.exe")); + } + + [TestMethod] + public void InnoUpgradeCleanupRunsOnlyAfterNewPayloadValidation() + { + var root = FindRepositoryRoot(); + var script = File.ReadAllText(Path.Combine(root, "installer", "ymhut_box_winui.iss")); + var installStep = script[script.IndexOf("procedure CurStepChanged", StringComparison.Ordinal)..]; + var filesStage = installStep[..installStep.IndexOf("else if CurStep = ssPostInstall", StringComparison.Ordinal)]; + var postInstall = installStep[installStep.IndexOf("else if CurStep = ssPostInstall", StringComparison.Ordinal)..]; + + Assert.DoesNotContain("[InstallDelete]", script, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("FinalizeLegacyInstallLayout", filesStage, StringComparison.Ordinal); + Assert.Contains("VerifyNewApplicationPayload", script, StringComparison.Ordinal); + Assert.Contains("FinalizeLegacyInstallLayout", postInstall, StringComparison.Ordinal); + Assert.Contains("data\\legacy-imports\\program-backup-", script, StringComparison.OrdinalIgnoreCase); + Assert.Contains("IsPreservedInstallPath", script, StringComparison.Ordinal); + Assert.Contains("if OldPayloadFiles.Count > 0 then", script, StringComparison.Ordinal); + Assert.Contains("DeleteKnownObsoletePayload(AppDir)", script, StringComparison.Ordinal); + Assert.Contains( + "else\n QuarantineUnknownLegacyFiles(AppDir, AppDir)", + script.Replace("\r\n", "\n", StringComparison.Ordinal), + StringComparison.Ordinal); + } + + [TestMethod] + public void CustomUninstallerOwnsThePublicEntryAndMsixExcludesIt() + { + var root = FindRepositoryRoot(); + var inno = File.ReadAllText(Path.Combine(root, "installer", "ymhut_box_winui.iss")); + var build = File.ReadAllText(Path.Combine(root, "scripts", "build-winui.ps1")); + var engine = File.ReadAllText(Path.Combine(root, "src", "YMhut.Box.InstallerBootstrap", "UninstallEngine.cs")); + + Assert.Contains("UninstallFilesDir={app}\\Runtime\\uninstall-engine", inno, StringComparison.Ordinal); + Assert.Contains("RegisterCustomUninstallEntry", inno, StringComparison.Ordinal); + Assert.Contains("QuietUninstallString", inno, StringComparison.Ordinal); + Assert.Contains("Runtime\\uninstall-engine\\unins000.dat", inno, StringComparison.Ordinal); + Assert.Contains("-p:BootstrapMode=Uninstall", build, StringComparison.Ordinal); + Assert.Contains("Join-Path $MsixStageRoot 'unins000.exe'", build, StringComparison.Ordinal); + Assert.Contains("RemoveUserData", engine, StringComparison.Ordinal); + Assert.Contains("/VERYSILENT", engine, StringComparison.Ordinal); + Assert.Contains("ScheduleBootstrapCleanup", engine, StringComparison.Ordinal); + Assert.Contains("UseShellExecute = false", engine, StringComparison.Ordinal); + var uninstallerWindow = File.ReadAllText(Path.Combine(root, "src", "YMhut.Box.InstallerBootstrap", "UninstallerWindow.cs")); + Assert.Contains("if (result.Success)", uninstallerWindow, StringComparison.Ordinal); + Assert.Contains("_running = false", uninstallerWindow, StringComparison.Ordinal); + Assert.IsLessThan( + inno.IndexOf("RegisterCustomUninstallEntry(ExpandConstant('{app}'));", StringComparison.Ordinal), + inno.IndexOf("FinalizeLegacyInstallLayout();", StringComparison.Ordinal)); + } + + [TestMethod] + public void RankBadgesUseCompiledPathIconsWithNumericFallback() + { + var root = FindRepositoryRoot(); + var source = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Controls", "RankBadgeFactory.cs")); + + Assert.Contains("new PathIcon", source, StringComparison.Ordinal); + Assert.Contains("CreateNumber(rank, size)", source, StringComparison.Ordinal); + Assert.DoesNotContain("http://", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("https://", source, StringComparison.OrdinalIgnoreCase); } [TestMethod] diff --git a/src/YMhut.Box.Tests/PearApiClientTests.cs b/src/YMhut.Box.Tests/PearApiClientTests.cs new file mode 100644 index 0000000..10f8fb4 --- /dev/null +++ b/src/YMhut.Box.Tests/PearApiClientTests.cs @@ -0,0 +1,344 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.RegularExpressions; +using YMhut.Box.Core.Api; + +namespace YMhut.Box.Tests; + +[TestClass] +public sealed class PearApiClientTests +{ + [TestMethod] + public void CatalogContainsTheVerified74DefinitionsAndOnlySafeEnabledPaths() + { + Assert.HasCount(74, PearApiCatalog.All); + Assert.HasCount(74, PearApiCatalog.All.Select(definition => definition.ToolId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()); + Assert.AreEqual("https://api.pearapi.ai/api/", PearApiClient.ServiceRoot.AbsoluteUri); + + var enabled = PearApiCatalog.All.Where(definition => definition.IsAvailableWithoutKey).ToArray(); + Assert.IsTrue(enabled.All(definition => definition.EndpointPath.StartsWith("/api/", StringComparison.Ordinal))); + Assert.IsTrue(enabled.All(definition => !definition.Parameters.Any(parameter => + parameter.Name.Replace("_", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal) + .Equals("apikey", StringComparison.OrdinalIgnoreCase) || + parameter.Name.Equals("key", StringComparison.OrdinalIgnoreCase) || + parameter.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)))); + + var disabled = PearApiCatalog.All.Where(definition => !definition.IsAvailableWithoutKey) + .Select(definition => definition.ToolId) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + CollectionAssert.AreEqual(new[] { "ai_latest_news", "pear_embeddings" }, disabled); + + CollectionAssert.AreEquivalent(new[] { "baidu_hot", "bili_hot", "zhihu_hot" }, PearApiCatalog.LegacyAliasToolIds.ToArray()); + Assert.AreEqual("百度", PearApiCatalog.GetRequired("baidu_hot").Parameters.Single(parameter => parameter.Name == "title").DefaultValue); + } + + [TestMethod] + public async Task RequestUsesOnlyFixedPearApiHostAndRemovesAuthenticationHeaders() + { + CapturedRequest? captured = null; + var handler = new StubHttpHandler(request => + { + captured = CapturedRequest.From(request); + return JsonResponse("{\"ok\":true}"); + }); + using var httpClient = new HttpClient(handler); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "secret"); + httpClient.DefaultRequestHeaders.Add("X-API-Key", "secret"); + using var client = new PearApiClient(httpClient); + + var response = await client.SendAsync(new PearApiRequest("pear_world_60")); + + Assert.IsTrue(response.Success); + Assert.IsNotNull(captured); + Assert.AreEqual("https", captured.Scheme); + Assert.AreEqual("api.pearapi.ai", captured.Host); + Assert.IsTrue(captured.Path.StartsWith("/api/", StringComparison.Ordinal)); + Assert.IsFalse(captured.HasAuthorization); + Assert.IsFalse(captured.HasApiKey); + } + + [TestMethod] + public async Task PublicParameterlessGetUsesMemoryCache() + { + var calls = 0; + using var httpClient = new HttpClient(new StubHttpHandler(_ => + { + calls++; + return JsonResponse("{\"data\":\"cached\"}"); + })); + using var client = new PearApiClient(httpClient); + + var first = await client.SendAsync(new PearApiRequest("pear_world_60")); + var second = await client.SendAsync(new PearApiRequest("pear_world_60")); + + Assert.IsTrue(first.Success); + Assert.IsTrue(second.Success); + Assert.IsFalse(first.IsFromCache); + Assert.IsTrue(second.IsFromCache); + Assert.AreEqual(1, calls); + } + + [TestMethod] + public async Task UserTextRequestsAreNeverCached() + { + var calls = 0; + using var httpClient = new HttpClient(new StubHttpHandler(_ => + { + calls++; + return JsonResponse("{\"data\":\"translated\"}"); + })); + using var client = new PearApiClient(httpClient); + var request = new PearApiRequest("pear_translation", new Dictionary { ["text"] = "hello" }); + + var first = await client.SendAsync(request); + var second = await client.SendAsync(request); + + Assert.IsTrue(first.Success); + Assert.IsTrue(second.Success); + Assert.IsFalse(first.IsFromCache); + Assert.IsFalse(second.IsFromCache); + Assert.AreEqual(2, calls); + } + + [TestMethod] + [DataRow("pear_short_recovery", "url", "http://127.0.0.1/private")] + [DataRow("pear_short_recovery", "url", "file:///C:/Windows/win.ini")] + [DataRow("pear_site_index", "url", "localhost")] + [DataRow("pear_site_index", "url", "example.com/path")] + public async Task PrivateOrInvalidUrlAndDomainParametersAreRejectedBeforeNetwork(string toolId, string name, string value) + { + var calls = 0; + using var httpClient = new HttpClient(new StubHttpHandler(_ => + { + calls++; + return JsonResponse("{}"); + })); + using var client = new PearApiClient(httpClient); + + var response = await client.SendAsync(new PearApiRequest(toolId, new Dictionary { [name] = value })); + + Assert.IsFalse(response.Success); + Assert.AreEqual(0, calls); + } + + [TestMethod] + public async Task UndeclaredParametersAreRejectedBeforeNetwork() + { + var calls = 0; + using var httpClient = new HttpClient(new StubHttpHandler(_ => + { + calls++; + return JsonResponse("{}"); + })); + using var client = new PearApiClient(httpClient); + + var response = await client.SendAsync(new PearApiRequest("pear_world_60", new Dictionary { ["key"] = "must-not-leave-device" })); + + Assert.IsFalse(response.Success); + StringAssert.Contains(response.Error ?? string.Empty, "未声明"); + Assert.AreEqual(0, calls); + } + + [TestMethod] + public async Task UploadRequiresConfirmationAndValidFileSignature() + { + var path = Path.GetTempFileName(); + await File.WriteAllTextAsync(path, "not an image"); + try + { + var calls = 0; + using var httpClient = new HttpClient(new StubHttpHandler(_ => + { + calls++; + return JsonResponse("{}"); + })); + using var client = new PearApiClient(httpClient); + + var unconfirmed = await client.SendAsync(new PearApiRequest("pear_ai_image_describe", FilePath: path)); + var invalidMime = await client.SendAsync(new PearApiRequest("pear_ai_image_describe", FilePath: path, UploadConfirmed: true)); + + Assert.IsFalse(unconfirmed.Success); + StringAssert.Contains(unconfirmed.Error ?? string.Empty, "确认"); + Assert.IsFalse(invalidMime.Success); + StringAssert.Contains(invalidMime.Error ?? string.Empty, "真实格式"); + Assert.AreEqual(0, calls); + } + finally + { + File.Delete(path); + } + } + + [TestMethod] + [DataRow("pear_ai_image_describe", 10, true)] + [DataRow("pear_audio_transcript", 50, false)] + public async Task UploadSizeLimitsAreEnforcedBeforeNetwork(string toolId, int limitMiB, bool image) + { + var path = Path.GetTempFileName(); + try + { + await using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) + { + var signature = image + ? new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } + : "ID3"u8.ToArray(); + await stream.WriteAsync(signature); + stream.SetLength(limitMiB * 1024L * 1024L + 1); + } + + var calls = 0; + using var httpClient = new HttpClient(new StubHttpHandler(_ => + { + calls++; + return JsonResponse("{}"); + })); + using var client = new PearApiClient(httpClient); + + var response = await client.SendAsync(new PearApiRequest(toolId, FilePath: path, UploadConfirmed: true)); + + Assert.IsFalse(response.Success); + StringAssert.Contains(response.Error ?? string.Empty, $"{limitMiB} MiB"); + Assert.AreEqual(0, calls); + } + finally + { + File.Delete(path); + } + } + + [TestMethod] + public async Task CancellationDoesNotReachNetwork() + { + var calls = 0; + using var httpClient = new HttpClient(new StubHttpHandler(_ => + { + calls++; + return JsonResponse("{}"); + })); + using var client = new PearApiClient(httpClient); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var response = await client.SendAsync( + new PearApiRequest("pear_translation", new Dictionary { ["text"] = "cancel" }), + cancellation.Token); + + Assert.IsFalse(response.Success); + StringAssert.Contains(response.Error ?? string.Empty, "取消"); + Assert.AreEqual(0, calls); + } + + [TestMethod] + public async Task OversizedResponseIsRejected() + { + using var httpClient = new HttpClient(new StubHttpHandler(_ => + { + var content = new ByteArrayContent([0]); + content.Headers.ContentLength = 8L * 1024 * 1024 + 1; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; + })); + using var client = new PearApiClient(httpClient); + + var response = await client.SendAsync(new PearApiRequest("pear_world_60")); + + Assert.IsFalse(response.Success); + Assert.IsNull(response.BinaryContent); + } + + [TestMethod] + public async Task ApiSourceIsRemovedRecursivelyFromResponsesAndNeverAcceptedAsInput() + { + CapturedRequest? captured = null; + using var httpClient = new HttpClient(new StubHttpHandler(request => + { + captured = CapturedRequest.From(request); + return JsonResponse(""" + { + "api_source": "https://brand.example/one", + "source": "kept", + "data": [ + { "apiSource": "https://brand.example/two", "title": "visible" }, + { "nested": { "API-SOURCE": "https://brand.example/three", "value": 7 } } + ] + } + """); + })); + using var client = new PearApiClient(httpClient); + + var response = await client.SendAsync(new PearApiRequest("pear_world_60")); + + Assert.IsTrue(response.Success, response.Error); + Assert.IsFalse(response.Content.Contains("brand.example", StringComparison.OrdinalIgnoreCase)); + Assert.IsFalse(response.Content.Contains("api_source", StringComparison.OrdinalIgnoreCase)); + Assert.IsFalse(response.Content.Contains("apiSource", StringComparison.Ordinal)); + Assert.IsTrue(response.Content.Contains("\"source\":\"kept\"", StringComparison.Ordinal)); + Assert.IsNotNull(captured); + Assert.IsTrue(PearApiPresentationSanitizer.IsHiddenField("api_source")); + Assert.IsTrue(PearApiPresentationSanitizer.IsHiddenField("apiSource")); + Assert.IsFalse(PearApiPresentationSanitizer.IsHiddenField("source")); + Assert.IsTrue(PearApiCatalog.All.All(definition => definition.Parameters.All(parameter => + !PearApiPresentationSanitizer.IsHiddenField(parameter.Name)))); + } + + [TestMethod] + public void GeneratedPearPagesAreDedicatedAndDoNotInheritAdaptiveToolPage() + { + var root = FindRepositoryRoot(); + var generated = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Views", "Tools", "GeneratedPearApiToolPages.cs")); + var pageMatches = Regex.Matches( + generated, + @"public sealed class \w+PearApiToolPage\([^\r\n]+\) : (?\w+)", + RegexOptions.CultureInvariant); + + Assert.HasCount(PearApiCatalog.All.Count + PearApiCatalog.LegacyAliasToolIds.Count, pageMatches); + Assert.IsTrue(pageMatches.Cast().All(match => match.Groups["base"].Value == "PearApiToolPageBase")); + Assert.IsFalse(generated.Contains(": AdaptiveToolPage", StringComparison.Ordinal)); + + var pageBase = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Views", "Tools", "PearApiToolPageBase.cs")); + Assert.IsFalse(pageBase.Contains(": AdaptiveToolPage", StringComparison.Ordinal)); + } + + private static HttpResponseMessage JsonResponse(string json) + => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private static string FindRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "YMhut.Box.Native.sln"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Repository root was not found."); + } + + private sealed class StubHttpHandler(Func handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(handler(request)); + } + } + + private sealed record CapturedRequest(string Scheme, string Host, string Path, bool HasAuthorization, bool HasApiKey) + { + public static CapturedRequest From(HttpRequestMessage request) + => new( + request.RequestUri?.Scheme ?? string.Empty, + request.RequestUri?.Host ?? string.Empty, + request.RequestUri?.AbsolutePath ?? string.Empty, + request.Headers.Authorization is not null || request.Headers.Contains("Authorization"), + request.Headers.Contains("X-API-Key")); + } +} diff --git a/src/YMhut.Box.Tests/PluginSecurityTests.cs b/src/YMhut.Box.Tests/PluginSecurityTests.cs index c6b0a60..bead2b7 100644 --- a/src/YMhut.Box.Tests/PluginSecurityTests.cs +++ b/src/YMhut.Box.Tests/PluginSecurityTests.cs @@ -1,5 +1,7 @@ using Microsoft.Data.Sqlite; using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO.Compression; +using System.Security.Cryptography; using YMhut.Box.Core.App; using YMhut.Box.Core.Plugins; using YMhut.Box.Core.Plugins.Runtime; @@ -200,7 +202,7 @@ public sealed class PluginSecurityTests File.WriteAllText(Path.Combine(created.Path!, "native.exe"), "not an executable"); var rejected = await service.ImportFolderAsync(created.Path!, targetRoot); Assert.IsFalse(rejected.Succeeded); - StringAssert.Contains(rejected.Message, "Unsafe"); + StringAssert.Contains(rejected.Message, "validation"); var clean = await service.CreateTemplateAsync(sourceRoot, "bridge-demo", "Bridge Demo", PluginTemplateKind.BridgeNetwork); var imported = await service.ImportFolderAsync(clean.Path!, targetRoot); @@ -220,6 +222,89 @@ public sealed class PluginSecurityTests Assert.IsFalse(uninstall.Succeeded); } + [TestMethod] + public void TauriToolBindingsRequireExactHashesAndRejectElevationOrInstallerRoles() + { + using var workspace = TempWorkspace(); + var root = CreatePackage(workspace.Path); + var executable = Path.Combine(root, "tool.exe"); + var library = Path.Combine(root, "tool.dll"); + File.WriteAllText(executable, "MZ controlled tool"); + File.WriteAllText(library, "MZ controlled library"); + var manifest = CreateTauriBindingManifest( + "tool.exe", + [ + new PluginToolFileSpec("tool.exe", Hash(executable)), + new PluginToolFileSpec("tool.dll", Hash(library)) + ]); + + Assert.IsEmpty(PluginRegistryService.ValidatePackage(manifest, root)); + + File.AppendAllText(library, "tampered"); + var tampered = PluginRegistryService.ValidatePackage(manifest, root); + Assert.IsTrue(tampered.Any(error => error.Contains("hash", StringComparison.OrdinalIgnoreCase))); + + File.WriteAllText(library, "MZ controlled library"); + File.WriteAllText(executable, "MZ "); + var elevatedManifest = CreateTauriBindingManifest( + "tool.exe", + [ + new PluginToolFileSpec("tool.exe", Hash(executable)), + new PluginToolFileSpec("tool.dll", Hash(library)) + ]); + Assert.IsTrue(PluginRegistryService.ValidatePackage(elevatedManifest, root) + .Any(error => error.Contains("elevation", StringComparison.OrdinalIgnoreCase))); + + var updater = Path.Combine(root, "updater.exe"); + File.Move(executable, updater, true); + var updaterManifest = CreateTauriBindingManifest( + "updater.exe", + [ + new PluginToolFileSpec("updater.exe", Hash(updater)), + new PluginToolFileSpec("tool.dll", Hash(library)) + ]); + Assert.IsTrue(PluginRegistryService.ValidatePackage(updaterManifest, root) + .Any(error => error.Contains("Installers", StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod] + public async Task YmpluginInventoryDetectsArchiveTampering() + { + using var workspace = TempWorkspace(); + var paths = AppPaths.ForCurrentUser(Path.Combine(workspace.Path, "app")); + var service = new PluginPackageService(paths, new PluginStateStore(paths)); + var pluginRoot = CreatePackage(Path.Combine(workspace.Path, "source")); + var manifest = StrictManifest() with { Id = "inventory-demo" }; + await File.WriteAllTextAsync( + Path.Combine(pluginRoot, PluginManifest.FileName), + System.Text.Json.JsonSerializer.Serialize(manifest, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web) + { + Converters = + { + new System.Text.Json.Serialization.JsonStringEnumConverter(), + new System.Text.Json.Serialization.JsonStringEnumConverter(), + new System.Text.Json.Serialization.JsonStringEnumConverter() + } + })); + var state = new PluginRuntimeState(manifest.Id, false, new HashSet(), new HashSet(), null); + var plugin = new LoadedPlugin(manifest, pluginRoot, state, []); + var packagePath = Path.Combine(workspace.Path, "inventory-demo.ymplugin"); + var exported = await service.ExportPackageAsync(plugin, packagePath); + Assert.IsTrue(exported.Succeeded, exported.Message); + + using (var archive = ZipFile.Open(packagePath, ZipArchiveMode.Update)) + { + archive.GetEntry("index.html")!.Delete(); + var replacement = archive.CreateEntry("index.html"); + await using var writer = new StreamWriter(replacement.Open()); + await writer.WriteAsync("tampered"); + } + + var imported = await service.ImportPackageAsync(packagePath, Path.Combine(workspace.Path, "target")); + Assert.IsFalse(imported.Succeeded); + StringAssert.Contains(imported.Message, "integrity"); + } + [TestMethod] public void ProtocolV2RoundTripsSessionBindingAndErrorCode() { @@ -277,6 +362,7 @@ public sealed class PluginSecurityTests ["clipboard.writeText"] = PluginPermission.Clipboard, ["file.openPicker"] = PluginPermission.FilePicker, ["file.savePicker"] = PluginPermission.FilePicker, + ["externalTool.launch"] = PluginPermission.ExternalTool, ["openExternal"] = PluginPermission.OpenExternal }; CollectionAssert.AreEquivalent(expected.Keys.ToArray(), PluginBridgePolicy.MethodPermissions.Keys.ToArray()); @@ -304,6 +390,46 @@ public sealed class PluginSecurityTests PermissionReasons: new Dictionary(), Network: new PluginNetworkSpec([], [], [])); + private static PluginManifest CreateTauriBindingManifest(string executable, IReadOnlyList files) => new( + "tauri-binding-test", + "Tauri Binding Test", + "1.0.0", + "tester", + "Controlled native tool test", + "index.html", + [PluginPermission.ExternalRuntime, PluginPermission.ExternalTool], + [new PluginSurface(PluginSurfaceKind.ToolboxTool, "main", "Main", "Main surface")], + ["index.html", "README.md"], + PluginRuntimeKind.Tauri, + Security: new PluginSecuritySpec([PluginPermission.ExternalRuntime, PluginPermission.ExternalTool]), + Tauri: new PluginTauriSpec(BuildOnCreate: false, RebuildOnMissingOrChanged: false, DistDirectory: "."), + ManifestVersion: 3, + ApiVersion: "2", + PermissionReasons: new Dictionary + { + [PluginPermission.ExternalRuntime] = "Render the prebuilt plugin surface.", + [PluginPermission.ExternalTool] = "Launch the declared and hashed test tool." + }, + Network: new PluginNetworkSpec([], [], []), + ToolBindings: + [ + new PluginToolBindingSpec( + "tool", + "Tool", + executable, + files, + ["--value", "{value}"], + [new PluginToolArgumentSpec("value", PluginToolArgumentKind.Integer, Required: true)], + ".", + "x64") + ]); + + private static string Hash(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)); + } + private static string CreatePackage(string root) { Directory.CreateDirectory(root); diff --git a/src/YMhut.Box.Tests/ToolCatalogTests.cs b/src/YMhut.Box.Tests/ToolCatalogTests.cs index 7a9f2d0..d6ca9f6 100644 --- a/src/YMhut.Box.Tests/ToolCatalogTests.cs +++ b/src/YMhut.Box.Tests/ToolCatalogTests.cs @@ -1,5 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Text.RegularExpressions; +using YMhut.Box.Core.Api; using YMhut.Box.Core.Tools; namespace YMhut.Box.Tests; @@ -48,13 +49,11 @@ public sealed class ToolCatalogTests public void GeneratedWinUiRegistrationsCoverEveryDefaultToolPage() { var repositoryRoot = FindRepositoryRoot(); - var source = File.ReadAllText(Path.Combine( - repositoryRoot, - "src", - "box-winUI", - "Views", - "Tools", - "GeneratedToolPages.cs")); + var toolsRoot = Path.Combine(repositoryRoot, "src", "box-winUI", "Views", "Tools"); + var source = string.Join( + Environment.NewLine, + File.ReadAllText(Path.Combine(toolsRoot, "GeneratedToolPages.cs")), + File.ReadAllText(Path.Combine(toolsRoot, "GeneratedPearApiToolPages.cs"))); var registered = Regex.Matches( source, "registry\\.Register<[^>]+>\\(\"(?[^\"]+)\"", @@ -69,6 +68,10 @@ public sealed class ToolCatalogTests .ToArray(); Assert.IsEmpty(missing, $"Missing WinUI tool registrations: {string.Join(", ", missing)}"); + foreach (var aliasId in PearApiCatalog.LegacyAliasToolIds) + { + Assert.DoesNotContain($"\"{aliasId}\"", File.ReadAllText(Path.Combine(toolsRoot, "GeneratedToolPages.cs")), StringComparison.Ordinal); + } } [TestMethod] diff --git a/src/YMhut.Box.Tests/ToolUiDefinitionTests.cs b/src/YMhut.Box.Tests/ToolUiDefinitionTests.cs index 611882e..c51ba30 100644 --- a/src/YMhut.Box.Tests/ToolUiDefinitionTests.cs +++ b/src/YMhut.Box.Tests/ToolUiDefinitionTests.cs @@ -70,8 +70,17 @@ public sealed class ToolUiDefinitionTests { var folder = Path.Combine(FindRepositoryRoot(), "assets", "data", "tool-ui"); var files = Directory.GetFiles(folder, "*.dat", SearchOption.TopDirectoryOnly); + var expectedIds = ToolCatalog.DefaultModules().Select(module => module.Id) + .Concat(NexNativeToolCatalog.Modules.Select(module => module.Id)) + .Concat(["plugin-host", "external-tool-host"]) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var actualIds = files.Select(Path.GetFileNameWithoutExtension) + .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) + .ToArray(); - Assert.IsGreaterThanOrEqualTo(202, files.Length); + CollectionAssert.AreEqual(expectedIds, actualIds); foreach (var file in files) { var bytes = File.ReadAllBytes(file); diff --git a/src/YMhut.Box.Tests/WeatherCapsuleLayoutTests.cs b/src/YMhut.Box.Tests/WeatherCapsuleLayoutTests.cs new file mode 100644 index 0000000..63bd9cb --- /dev/null +++ b/src/YMhut.Box.Tests/WeatherCapsuleLayoutTests.cs @@ -0,0 +1,25 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YMhut.Box.WinUI.Controls; + +namespace YMhut.Box.Tests; + +[TestClass] +public sealed class WeatherCapsuleLayoutTests +{ + [TestMethod] + public void WeatherCapsuleDegradesThroughEveryStableMode() + { + Assert.AreEqual(WeatherCapsuleDisplayMode.Full, WeatherCapsuleLayout.SelectMode(168)); + Assert.AreEqual(WeatherCapsuleDisplayMode.Compact, WeatherCapsuleLayout.SelectMode(120)); + Assert.AreEqual(WeatherCapsuleDisplayMode.IconOnly, WeatherCapsuleLayout.SelectMode(40)); + Assert.AreEqual(WeatherCapsuleDisplayMode.Hidden, WeatherCapsuleLayout.SelectMode(39)); + } + + [TestMethod] + public void LargerFontScaleRequiresMoreRoomForTextModes() + { + Assert.AreEqual(WeatherCapsuleDisplayMode.Full, WeatherCapsuleLayout.SelectMode(210, 1)); + Assert.AreEqual(WeatherCapsuleDisplayMode.Compact, WeatherCapsuleLayout.SelectMode(210, 1.5)); + Assert.AreEqual(WeatherCapsuleDisplayMode.IconOnly, WeatherCapsuleLayout.SelectMode(90, 1.5)); + } +} diff --git a/src/YMhut.Box.Tests/YMhut.Box.Tests.csproj b/src/YMhut.Box.Tests/YMhut.Box.Tests.csproj index 2e4a393..bae30e0 100644 --- a/src/YMhut.Box.Tests/YMhut.Box.Tests.csproj +++ b/src/YMhut.Box.Tests/YMhut.Box.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/src/YMhut.Box.Worker/YMhut.Box.Worker.csproj b/src/YMhut.Box.Worker/YMhut.Box.Worker.csproj index bd334f4..164fccf 100644 --- a/src/YMhut.Box.Worker/YMhut.Box.Worker.csproj +++ b/src/YMhut.Box.Worker/YMhut.Box.Worker.csproj @@ -6,6 +6,7 @@ enable YMhut.Box.Worker YMhut.Box.Worker + ..\box-winUI\Assets\app_icon.ico none diff --git a/src/box-winUI/AgreementWindow.cs b/src/box-winUI/AgreementWindow.cs index 0b58614..ede10fb 100644 --- a/src/box-winUI/AgreementWindow.cs +++ b/src/box-winUI/AgreementWindow.cs @@ -266,7 +266,7 @@ public sealed class AgreementWindow : Window BorderThickness = new Thickness(1), Child = new Image { - Source = new BitmapImage(new Uri("ms-appx:///Assets/icons/app_icon.png")), + Source = AppServices.GetRequiredService().CreateBitmapImage("icons/app_icon.png"), Width = 34, Height = 34, HorizontalAlignment = HorizontalAlignment.Center, diff --git a/src/box-winUI/App.xaml.cs b/src/box-winUI/App.xaml.cs index 0a57940..da3f01b 100644 --- a/src/box-winUI/App.xaml.cs +++ b/src/box-winUI/App.xaml.cs @@ -50,11 +50,13 @@ public partial class App : Application Trace($"OnLaunched:independent-host:{hostOptions.Kind}"); AppServices.Configure( string.IsNullOrWhiteSpace(hostOptions.Root) ? null : hostOptions.Root); + await AppServices.GetRequiredService().EnsureLoadedAsync(); await LaunchIndependentWindowHostAsync(hostOptions); return; } AppServices.Configure(); + await AppServices.GetRequiredService().EnsureLoadedAsync(); CrashLog.Write("Launch started."); var startupInitialization = AppServices.GetRequiredService(); var settingsService = AppServices.GetRequiredService(); diff --git a/src/box-winUI/Assets/LockScreenLogo.png b/src/box-winUI/Assets/LockScreenLogo.png index c0e57c6..9249487 100644 Binary files a/src/box-winUI/Assets/LockScreenLogo.png and b/src/box-winUI/Assets/LockScreenLogo.png differ diff --git a/src/box-winUI/Assets/Square150x150Logo.png b/src/box-winUI/Assets/Square150x150Logo.png index f482e4e..3c6c01b 100644 Binary files a/src/box-winUI/Assets/Square150x150Logo.png and b/src/box-winUI/Assets/Square150x150Logo.png differ diff --git a/src/box-winUI/Assets/Square44x44Logo.png b/src/box-winUI/Assets/Square44x44Logo.png index 3a37741..c424279 100644 Binary files a/src/box-winUI/Assets/Square44x44Logo.png and b/src/box-winUI/Assets/Square44x44Logo.png differ diff --git a/src/box-winUI/Assets/StoreLogo.png b/src/box-winUI/Assets/StoreLogo.png index f1cdf82..b2e78bd 100644 Binary files a/src/box-winUI/Assets/StoreLogo.png and b/src/box-winUI/Assets/StoreLogo.png differ diff --git a/src/box-winUI/Assets/Wide310x150Logo.png b/src/box-winUI/Assets/Wide310x150Logo.png index 6530c38..c07f8a7 100644 Binary files a/src/box-winUI/Assets/Wide310x150Logo.png and b/src/box-winUI/Assets/Wide310x150Logo.png differ diff --git a/src/box-winUI/Assets/app_icon.ico b/src/box-winUI/Assets/app_icon.ico index 08918ba..ab342b9 100644 Binary files a/src/box-winUI/Assets/app_icon.ico and b/src/box-winUI/Assets/app_icon.ico differ diff --git a/src/box-winUI/Controls/AppTitleBar.cs b/src/box-winUI/Controls/AppTitleBar.cs index 4e7239f..babd22d 100644 --- a/src/box-winUI/Controls/AppTitleBar.cs +++ b/src/box-winUI/Controls/AppTitleBar.cs @@ -2,6 +2,7 @@ using Microsoft.UI.Text; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media.Imaging; using Windows.UI.Text; using YMhut.Box.WinUI.Services; @@ -53,6 +54,10 @@ internal sealed class AppTitleBar : Grid VerticalAlignment = VerticalAlignment.Center, Children = { WeatherCapsule, CloudButton, QuickSettingsButton, ThemeToggleButton } }; + ActionsPanel.SizeChanged += (_, args) => ActionsPanel.Clip = new RectangleGeometry + { + Rect = new Windows.Foundation.Rect(0, 0, Math.Max(0, args.NewSize.Width), Math.Max(0, args.NewSize.Height)) + }; Grid.SetColumn(ActionsPanel, 2); Children.Add(ActionsPanel); } @@ -103,7 +108,7 @@ internal sealed class AppTitleBar : Grid Background = ModernUi.Transparent, Child = new Image { - Source = new BitmapImage(new Uri("ms-appx:///Assets/icons/app_icon.png")), + Source = AppServices.GetRequiredService().CreateBitmapImage("icons/app_icon.png"), Width = 20, Height = 20, HorizontalAlignment = HorizontalAlignment.Center, diff --git a/src/box-winUI/Controls/RankBadgeFactory.cs b/src/box-winUI/Controls/RankBadgeFactory.cs new file mode 100644 index 0000000..1899c05 --- /dev/null +++ b/src/box-winUI/Controls/RankBadgeFactory.cs @@ -0,0 +1,85 @@ +using Microsoft.UI.Text; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Markup; +using Microsoft.UI.Xaml.Media; + +namespace YMhut.Box.WinUI.Controls; + +internal static class RankBadgeFactory +{ + private const string MedalPath = "M12,1 C7.58,1 4,4.58 4,9 C4,11.79 5.43,14.25 7.6,15.68 L6,23 L12,19.8 L18,23 L16.4,15.68 C18.57,14.25 20,11.79 20,9 C20,4.58 16.42,1 12,1 Z"; + + public static FrameworkElement Create(int rank, double size = 32) + { + rank = Math.Max(1, rank); + FrameworkElement element = rank <= 3 ? CreateMedal(rank, size) : CreateNumber(rank, size); + AutomationProperties.SetName(element, AppLocalizer.T($"第 {rank} 名", $"Rank {rank}")); + return element; + } + + private static FrameworkElement CreateMedal(int rank, double size) + { + try + { + var foreground = rank switch + { + 1 => ModernUi.Warning, + 2 => ModernUi.TextSecondary, + _ => ModernUi.Brush("#B87333") + }; + return new Grid + { + Width = size, + Height = size, + Children = + { + new PathIcon + { + Data = (Geometry)XamlBindingHelper.ConvertValue(typeof(Geometry), MedalPath), + Foreground = foreground, + Width = size - 4, + Height = size - 4 + }, + new TextBlock + { + Text = rank.ToString(), + FontSize = Math.Max(9, size * 0.3), + FontWeight = FontWeights.Bold, + Foreground = ModernUi.Surface, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 0, size * 0.17) + } + } + }; + } + catch + { + return CreateNumber(rank, size); + } + } + + private static FrameworkElement CreateNumber(int rank, double size) + { + return new Border + { + Width = size, + Height = size, + CornerRadius = new CornerRadius(Math.Min(6, size / 4)), + Background = ModernUi.SurfaceAlt, + BorderBrush = ModernUi.Stroke, + BorderThickness = new Thickness(1), + Child = new TextBlock + { + Text = rank.ToString(), + FontSize = Math.Max(10, size * 0.36), + FontWeight = FontWeights.SemiBold, + Foreground = ModernUi.TextSecondary, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + }; + } +} diff --git a/src/box-winUI/Controls/WeatherCapsuleControl.cs b/src/box-winUI/Controls/WeatherCapsuleControl.cs index b9332aa..d11e566 100644 --- a/src/box-winUI/Controls/WeatherCapsuleControl.cs +++ b/src/box-winUI/Controls/WeatherCapsuleControl.cs @@ -24,6 +24,8 @@ public sealed class WeatherCapsuleControl : UserControl private readonly TextBlock _locationText; private readonly TextBlock _tempText; private readonly TextBlock _conditionText; + private readonly Grid _visual; + private readonly Grid _detailsPanel; private readonly Flyout _flyout; private TitleWeatherSnapshot _snapshot = TitleWeatherSnapshot.Loading; @@ -57,7 +59,7 @@ public sealed class WeatherCapsuleControl : UserControl ConfigureCompactLine(_conditionText, 13); ConfigureCompactLine(_tempText, 18); - var visual = new Grid + _visual = new Grid { ColumnSpacing = 9, VerticalAlignment = VerticalAlignment.Center, @@ -69,8 +71,8 @@ public sealed class WeatherCapsuleControl : UserControl } }; var iconHost = new Grid { Width = 22, Height = 22, Children = { _weatherIcon, _loadingRing } }; - visual.Children.Add(iconHost); - var text = new Grid + _visual.Children.Add(iconHost); + _detailsPanel = new Grid { MinWidth = 76, MaxWidth = 130, @@ -81,13 +83,13 @@ public sealed class WeatherCapsuleControl : UserControl new RowDefinition { Height = GridLength.Auto } } }; - text.Children.Add(_locationText); + _detailsPanel.Children.Add(_locationText); Grid.SetRow(_conditionText, 1); - text.Children.Add(_conditionText); - Grid.SetColumn(text, 1); - visual.Children.Add(text); + _detailsPanel.Children.Add(_conditionText); + Grid.SetColumn(_detailsPanel, 1); + _visual.Children.Add(_detailsPanel); Grid.SetColumn(_tempText, 2); - visual.Children.Add(_tempText); + _visual.Children.Add(_tempText); var flyoutStyle = new Style(typeof(FlyoutPresenter)); flyoutStyle.Setters.Add(new Setter(Control.PaddingProperty, new Thickness(12))); @@ -113,17 +115,66 @@ public sealed class WeatherCapsuleControl : UserControl Background = ModernUi.Surface, BorderBrush = ModernUi.Stroke, BorderThickness = new Thickness(1), - Content = visual, + Content = _visual, Flyout = _flyout }; AutomationProperties.SetName(_button, AppLocalizer.T("天气", "Weather")); ToolTipService.SetToolTip(_button, AppLocalizer.T("查看天气详情", "Show weather details")); Content = _button; + SetDisplayMode(WeatherCapsuleDisplayMode.Full); ApplySnapshot(_snapshot); Loaded += WeatherCapsuleControl_Loaded; Unloaded += WeatherCapsuleControl_Unloaded; } + public WeatherCapsuleDisplayMode DisplayMode { get; private set; } + + public void SetDisplayMode(WeatherCapsuleDisplayMode mode) + { + DisplayMode = mode; + Visibility = mode == WeatherCapsuleDisplayMode.Hidden + ? Visibility.Collapsed + : Visibility.Visible; + + _detailsPanel.Visibility = mode == WeatherCapsuleDisplayMode.Full + ? Visibility.Visible + : Visibility.Collapsed; + _tempText.Visibility = mode is WeatherCapsuleDisplayMode.Full or WeatherCapsuleDisplayMode.Compact + ? Visibility.Visible + : Visibility.Collapsed; + _visual.ColumnSpacing = mode == WeatherCapsuleDisplayMode.Full ? 9 : 7; + _visual.ColumnDefinitions[1].Width = mode == WeatherCapsuleDisplayMode.Full + ? new GridLength(1, GridUnitType.Star) + : new GridLength(0); + _visual.ColumnDefinitions[2].Width = mode is WeatherCapsuleDisplayMode.Full or WeatherCapsuleDisplayMode.Compact + ? GridLength.Auto + : new GridLength(0); + + switch (mode) + { + case WeatherCapsuleDisplayMode.Full: + _button.Width = double.NaN; + _button.MinWidth = 172; + _button.MaxWidth = 248; + _button.Padding = new Thickness(12, 4, 12, 4); + break; + case WeatherCapsuleDisplayMode.Compact: + _button.Width = double.NaN; + _button.MinWidth = 76; + _button.MaxWidth = 108; + _button.Padding = new Thickness(10, 4, 10, 4); + break; + case WeatherCapsuleDisplayMode.IconOnly: + _button.Width = 40; + _button.MinWidth = 40; + _button.MaxWidth = 40; + _button.Padding = new Thickness(8); + break; + case WeatherCapsuleDisplayMode.Hidden: + break; + } + } + public async Task LoadAsync(CancellationToken cancellationToken = default) { await RefreshAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/box-winUI/Controls/WeatherCapsuleDisplayMode.cs b/src/box-winUI/Controls/WeatherCapsuleDisplayMode.cs new file mode 100644 index 0000000..84605e1 --- /dev/null +++ b/src/box-winUI/Controls/WeatherCapsuleDisplayMode.cs @@ -0,0 +1,30 @@ +namespace YMhut.Box.WinUI.Controls; + +public enum WeatherCapsuleDisplayMode +{ + Full, + Compact, + IconOnly, + Hidden +} + +public static class WeatherCapsuleLayout +{ + public static WeatherCapsuleDisplayMode SelectMode(double availableWidth, double fontScale = 1) + { + var scale = Math.Clamp(fontScale, 1, 2); + if (availableWidth >= 168 * scale) + { + return WeatherCapsuleDisplayMode.Full; + } + + if (availableWidth >= 72 * scale) + { + return WeatherCapsuleDisplayMode.Compact; + } + + return availableWidth >= 40 + ? WeatherCapsuleDisplayMode.IconOnly + : WeatherCapsuleDisplayMode.Hidden; + } +} diff --git a/src/box-winUI/MainWindow.xaml.cs b/src/box-winUI/MainWindow.xaml.cs index defcb6f..65af391 100644 --- a/src/box-winUI/MainWindow.xaml.cs +++ b/src/box-winUI/MainWindow.xaml.cs @@ -74,6 +74,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost private string? _activePluginSurfaceId; private StackPanel? _titleActionsPanel; private WinDispatcherQueueTimer? _responsiveShellTimer; + private WinDispatcherQueueTimer? _moveRecoveryTimer; private WindowMovePerformanceHelper? _movePerformanceHelper; private IDisposable? _moveLightModeScope; private bool _hasResponsiveShellState; @@ -81,6 +82,8 @@ public sealed partial class MainWindow : Window, IShellNavigationHost private bool _lastResponsiveDesktop; private bool _lastResponsivePhone; private double _lastResponsiveWidth; + private double _lastResponsiveFontScale; + private double _lastResponsiveRasterizationScale; private bool _windowMoveLoopActive; private bool _responsiveShellUpdateDeferred; private AppShell? _shell; @@ -144,6 +147,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost _windowMoveLoopActive = active; if (active) { + _moveRecoveryTimer?.Stop(); _moveLightModeScope ??= _uiPerformanceCoordinator.EnterLightMode("main-window-move"); _responsiveShellTimer?.Stop(); _pageTransitionStoryboard?.Stop(); @@ -151,16 +155,31 @@ public sealed partial class MainWindow : Window, IShellNavigationHost } else { - _moveLightModeScope?.Dispose(); - _moveLightModeScope = null; - if (_responsiveShellUpdateDeferred) + _moveRecoveryTimer ??= CreateOneShotTimer( + TimeSpan.FromMilliseconds(72), + () => + { + if (_windowMoveLoopActive || _isClosed) + { + return; + } + + _moveLightModeScope?.Dispose(); + _moveLightModeScope = null; + ApplyShellTheme(); + if (_responsiveShellUpdateDeferred) + { + _responsiveShellUpdateDeferred = false; + ApplyResponsiveShell(); + } + }); + _moveRecoveryTimer.Stop(); + _moveRecoveryTimer.Start(); + if (!_responsiveShellUpdateDeferred) { - _responsiveShellUpdateDeferred = false; - ScheduleResponsiveShellUpdate(); + _responsiveShellUpdateDeferred = true; } } - - ApplyShellTheme(); }); ToastService.Attach(ToastPresenter); _shellNotificationService.Attach(ToastPresenter); @@ -203,6 +222,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost _settingsService.PropertyChanged -= _settingsChangedHandler; UnsubscribeSystemVisualEvents(); _glassIntensitySaveDebounce.Stop(); + _moveRecoveryTimer?.Stop(); _globalHotkeyService.Invoked -= GlobalHotkeyService_Invoked; _globalHotkeyService.Dispose(); _desktopOverlayService.Dispose(); @@ -227,6 +247,13 @@ public sealed partial class MainWindow : Window, IShellNavigationHost CrashLog.Write(exception); } }; + RootLayout.Loaded += (_, _) => + { + if (RootLayout.XamlRoot is not null) + { + RootLayout.XamlRoot.Changed += (_, _) => ScheduleResponsiveShellUpdate(); + } + }; StartupTrace.Write("MainWindow.ctor:after-build"); } @@ -2193,6 +2220,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost { await _settingsService.UpdateAsync(settings => settings.TopBarMaterial = material); ApplyShellTheme(); + ScheduleResponsiveShellUpdate(); RefreshQuickSettingsControls(); } @@ -2317,6 +2345,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost ? Math.Max(240, Math.Min(desiredQuickSettingsWidth, RootLayout.ActualWidth - 12)) : desiredQuickSettingsWidth; ApplyShellTheme(); + ScheduleResponsiveShellUpdate(); } private void AnimateQuickSettings(bool show) @@ -2490,10 +2519,14 @@ public sealed partial class MainWindow : Window, IShellNavigationHost var wide = width >= 1280; var desktop = width >= 760; var phone = width < 520; + var fontScale = _settingsService.Current.FontSize; + var rasterizationScale = AppTitleBar.XamlRoot?.RasterizationScale ?? 1; var bucketChanged = !_hasResponsiveShellState || wide != _lastResponsiveWide || desktop != _lastResponsiveDesktop || - phone != _lastResponsivePhone; + phone != _lastResponsivePhone || + Math.Abs(fontScale - _lastResponsiveFontScale) >= 0.01 || + Math.Abs(rasterizationScale - _lastResponsiveRasterizationScale) >= 0.01; var widthChangedEnough = !_hasResponsiveShellState || Math.Abs(width - _lastResponsiveWidth) >= 24; @@ -2518,15 +2551,24 @@ public sealed partial class MainWindow : Window, IShellNavigationHost GlobalSearchBox.Visibility = Visibility.Visible; GlobalSearchBox.MaxWidth = Math.Max(180, RootNavigation.OpenPaneLength - 32); - if (WeatherCapsule is not null) - { - WeatherCapsule.Visibility = desktop && width >= 960 ? Visibility.Visible : Visibility.Collapsed; - } if (_titleActionsPanel is not null) { var captionReserve = _windowChromeService.CaptionButtonReservedWidth(this, AppTitleBar.XamlRoot); _shell?.TitleBar.SetCaptionButtonReserve(captionReserve); _titleActionsPanel.Margin = new Thickness(0); + var brandReserve = phone ? 132d : 152d; + var dragReserve = phone ? 36d : 64d; + var availableActionsWidth = Math.Max( + 108, + width - captionReserve - brandReserve - dragReserve - 28); + _titleActionsPanel.MaxWidth = Math.Min(360, availableActionsWidth); + if (WeatherCapsule is not null) + { + const double fixedActionWidth = (32 * 3) + (6 * 3); + var weatherWidth = Math.Max(0, availableActionsWidth - fixedActionWidth); + var mode = WeatherCapsuleLayout.SelectMode(weatherWidth, fontScale); + WeatherCapsule.SetDisplayMode(mode); + } } _hasResponsiveShellState = true; @@ -2534,6 +2576,8 @@ public sealed partial class MainWindow : Window, IShellNavigationHost _lastResponsiveDesktop = desktop; _lastResponsivePhone = phone; _lastResponsiveWidth = width; + _lastResponsiveFontScale = fontScale; + _lastResponsiveRasterizationScale = rasterizationScale; if (bucketChanged) { diff --git a/src/box-winUI/Services/AppServices.cs b/src/box-winUI/Services/AppServices.cs index 09923df..723e222 100644 --- a/src/box-winUI/Services/AppServices.cs +++ b/src/box-winUI/Services/AppServices.cs @@ -44,7 +44,12 @@ public static class AppServices provider.GetRequiredService(), provider.GetService())); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(provider => new TauriPluginProcessService( @@ -89,6 +94,8 @@ public static class AppServices provider.GetRequiredService(), provider.GetService(), provider.GetRequiredService())); + services.AddSingleton(provider => new PearApiClient( + logService: provider.GetService())); services.AddSingleton(provider => new RemoteMediaCatalogService( provider.GetRequiredService(), provider.GetRequiredService(), @@ -124,6 +131,9 @@ public static class AppServices provider.GetRequiredService(), provider.GetService())); services.AddSingleton(); + services.AddSingleton(provider => new RuntimeAssetService( + provider.GetRequiredService(), + provider.GetService())); services.AddSingleton(provider => new ToolUiDefinitionStore( provider.GetRequiredService(), provider.GetService())); @@ -165,7 +175,7 @@ public static class AppServices provider.GetService(), provider.GetService())); services.AddSingleton(provider => new LocalToolVendorAssetService( - provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetService())); services.AddSingleton(); services.AddSingleton(provider => new ProcessToolWorkerService( diff --git a/src/box-winUI/Services/KillOnCloseJob.cs b/src/box-winUI/Services/KillOnCloseJob.cs new file mode 100644 index 0000000..dd50454 --- /dev/null +++ b/src/box-winUI/Services/KillOnCloseJob.cs @@ -0,0 +1,106 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace YMhut.Box.WinUI.Services; + +internal sealed class KillOnCloseJob : IDisposable +{ + private readonly SafeJobHandle _handle; + + public KillOnCloseJob() + { + _handle = CreateJobObject(nint.Zero, null); + if (_handle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Unable to create a plugin process Job Object."); + } + var information = new JobObjectExtendedLimitInformation + { + BasicLimitInformation = new JobObjectBasicLimitInformation { LimitFlags = 0x00002000 } + }; + var size = Marshal.SizeOf(); + var pointer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(information, pointer, false); + if (!SetInformationJobObject(_handle, 9, pointer, (uint)size)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Unable to configure a plugin process Job Object."); + } + } + finally + { + Marshal.FreeHGlobal(pointer); + } + } + + public void Add(Process process) + { + if (!AssignProcessToJobObject(_handle, process.Handle)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Unable to assign a plugin process to its Job Object."); + } + } + + public void Dispose() => _handle.Dispose(); + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectBasicLimitInformation + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public nuint MinimumWorkingSetSize; + public nuint MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public nuint Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectExtendedLimitInformation + { + public JobObjectBasicLimitInformation BasicLimitInformation; + public IoCounters IoInfo; + public nuint ProcessMemoryLimit; + public nuint JobMemoryLimit; + public nuint PeakProcessMemoryUsed; + public nuint PeakJobMemoryUsed; + } + + private sealed class SafeJobHandle : SafeHandleZeroOrMinusOneIsInvalid + { + private SafeJobHandle() : base(true) { } + + protected override bool ReleaseHandle() => CloseHandle(handle); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeJobHandle CreateJobObject(nint jobAttributes, string? name); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject(SafeJobHandle job, int informationClass, nint information, uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(SafeJobHandle job, nint process); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(nint handle); +} diff --git a/src/box-winUI/Services/LocalToolVendorAssetService.cs b/src/box-winUI/Services/LocalToolVendorAssetService.cs index 878b41e..65140de 100644 --- a/src/box-winUI/Services/LocalToolVendorAssetService.cs +++ b/src/box-winUI/Services/LocalToolVendorAssetService.cs @@ -1,8 +1,3 @@ -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using YMhut.Box.Core.App; using YMhut.Box.Core.Logging; namespace YMhut.Box.WinUI.Services; @@ -36,15 +31,9 @@ public sealed record LocalJsToolRuntimeResult( string Runtime = "csharp-fallback"); public sealed class LocalToolVendorAssetService( - AppPaths paths, + IRuntimeAssetService runtimeAssets, ILogService? logService = null) : ILocalToolVendorAssetService { - private const string ResourcePrefix = "YMhut.Box.WinUI.Assets.tool-results.vendor."; - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = false - }; - private readonly SemaphoreSlim _gate = new(1, 1); private LocalToolVendorAssetSnapshot? _snapshot; @@ -63,60 +52,21 @@ public sealed class LocalToolVendorAssetService( return _snapshot; } - var assembly = Assembly.GetExecutingAssembly(); - var resources = assembly.GetManifestResourceNames() - .Where(name => name.StartsWith(ResourcePrefix, StringComparison.OrdinalIgnoreCase)) - .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (resources.Length == 0) - { - _snapshot = await SnapshotFromContentFolderAsync(cancellationToken).ConfigureAwait(false); - return _snapshot; - } - - var manifestResource = resources.FirstOrDefault(name => name.EndsWith(".manifest.json", StringComparison.OrdinalIgnoreCase)) ?? - resources.FirstOrDefault(name => name.EndsWith("manifest.json", StringComparison.OrdinalIgnoreCase)); - var manifestHash = manifestResource is null - ? HashText(string.Join("|", resources)) - : await HashResourceAsync(assembly, manifestResource, cancellationToken).ConfigureAwait(false); - var cacheRoot = Path.Combine(paths.Cache, "tool-vendor", manifestHash); - Directory.CreateDirectory(cacheRoot); - - var extracted = new List(); - foreach (var resource in resources) - { - cancellationToken.ThrowIfCancellationRequested(); - var relative = ResourceToRelativePath(resource); - if (string.IsNullOrWhiteSpace(relative)) - { - continue; - } - - var target = Path.Combine(cacheRoot, relative); - Directory.CreateDirectory(Path.GetDirectoryName(target)!); - await using var source = assembly.GetManifestResourceStream(resource); - if (source is null) - { - continue; - } - - await using var output = File.Create(target); - await source.CopyToAsync(output, cancellationToken).ConfigureAwait(false); - extracted.Add(target); - } - + var runtime = await runtimeAssets.EnsureLoadedAsync(cancellationToken).ConfigureAwait(false); + var files = runtimeAssets.GetEntries("tool-results/vendor"); + const string manifestPath = "runtime-assets.dat!/tool-results/vendor/manifest.json"; _snapshot = new LocalToolVendorAssetSnapshot( - cacheRoot, - Path.Combine(cacheRoot, "manifest.json"), - manifestHash, - extracted); + "runtime-assets.dat!/tool-results/vendor", + manifestPath, + runtime.PackageHash, + files); if (logService is not null) { await logService.WriteAsync( "Information", "tool-vendor", - "Local JS vendor assets extracted", - $"files={extracted.Count}; hash={manifestHash}", + "Local JS vendor assets verified in authenticated package", + $"files={files.Count}; package={runtime.PackageHash[..12]}", cancellationToken).ConfigureAwait(false); } return _snapshot; @@ -127,41 +77,6 @@ public sealed class LocalToolVendorAssetService( } } - private async Task SnapshotFromContentFolderAsync(CancellationToken cancellationToken) - { - var sourceRoot = Path.Combine(paths.Assets, "tool-results", "vendor"); - var files = Directory.Exists(sourceRoot) - ? Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories).OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToArray() - : []; - var manifest = Path.Combine(sourceRoot, "manifest.json"); - var hash = HashText(string.Join("|", files.Select(Path.GetFileName))); - if (File.Exists(manifest)) - { - await using var manifestStream = File.OpenRead(manifest); - hash = Convert.ToHexString(await SHA256.HashDataAsync(manifestStream, cancellationToken).ConfigureAwait(false)).ToLowerInvariant(); - } - return new LocalToolVendorAssetSnapshot(sourceRoot, manifest, hash, files); - } - - private static string ResourceToRelativePath(string resource) - { - var name = resource[ResourcePrefix.Length..]; - if (name.StartsWith("vendor.", StringComparison.OrdinalIgnoreCase)) - { - name = name["vendor.".Length..]; - } - - return name; - } - - private static async Task HashResourceAsync(Assembly assembly, string resource, CancellationToken cancellationToken) - { - await using var stream = assembly.GetManifestResourceStream(resource) ?? new MemoryStream(); - return Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)).ToLowerInvariant(); - } - - private static string HashText(string value) - => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); } public sealed class LocalJsToolRuntime(ILocalToolVendorAssetService vendorAssets) : ILocalJsToolRuntime diff --git a/src/box-winUI/Services/PluginHostProcessService.cs b/src/box-winUI/Services/PluginHostProcessService.cs index 20179b7..61036a8 100644 --- a/src/box-winUI/Services/PluginHostProcessService.cs +++ b/src/box-winUI/Services/PluginHostProcessService.cs @@ -36,6 +36,8 @@ public interface IPluginHostProcessService : IDisposable void ResetFailedState(); + PluginSnapshot InvalidateSnapshot(string? pluginsRoot = null); + void Stop(); } @@ -64,11 +66,6 @@ public sealed class PluginHostProcessService(ILogService? logService = null) : I public async Task GetSnapshotAsync(CancellationToken cancellationToken = default) { - if (CurrentSnapshot is not null && Status == PluginHostStatus.Failed) - { - return CurrentSnapshot; - } - var response = await SendRequestAsync(new PluginHostMessage(PluginHostProtocol.GetSnapshot), cancellationToken).ConfigureAwait(false); return ApplySnapshot(response.Snapshot); } @@ -173,6 +170,19 @@ public sealed class PluginHostProcessService(ILogService? logService = null) : I SetStatus(PluginHostStatus.Stopped); } + public PluginSnapshot InvalidateSnapshot(string? pluginsRoot = null) + { + var snapshot = new PluginSnapshot( + CurrentSnapshot?.PluginsEnabled ?? true, + string.IsNullOrWhiteSpace(pluginsRoot) ? CurrentSnapshot?.PluginsRoot ?? string.Empty : pluginsRoot, + [], + [], + DateTimeOffset.Now); + CurrentSnapshot = snapshot; + SnapshotChanged?.Invoke(this, snapshot); + return snapshot; + } + public void Dispose() { if (_disposed) diff --git a/src/box-winUI/Services/PluginRuntimeDependencyService.cs b/src/box-winUI/Services/PluginRuntimeDependencyService.cs new file mode 100644 index 0000000..a1cff29 --- /dev/null +++ b/src/box-winUI/Services/PluginRuntimeDependencyService.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using YMhut.Box.Core.Plugins.Runtime; + +namespace YMhut.Box.WinUI.Services; + +public sealed class PluginRuntimeDependencyService : IPluginRuntimeDependencyService, IPluginToolchainLocator +{ + public Task GetAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var root = Path.Combine(AppContext.BaseDirectory, "tauri-host"); + var executable = Path.Combine(root, "ymhut-box-plugin-tauri-host.exe"); + var manifestPath = Path.Combine(root, "runtime.json"); + var templatePath = Path.Combine(root, "template.ymtemplate"); + var templateEntryPath = Path.Combine(root, "template", "dist", "index.html"); + var templateReadmePath = Path.Combine(root, "template", "README.md"); + var hostApiVersion = string.Empty; + var templateVersion = string.Empty; + try + { + if (File.Exists(manifestPath)) + { + using var document = JsonDocument.Parse(File.ReadAllText(manifestPath)); + hostApiVersion = document.RootElement.TryGetProperty("hostApiVersion", out var hostApi) ? hostApi.GetString() ?? string.Empty : string.Empty; + templateVersion = document.RootElement.TryGetProperty("templateVersion", out var template) ? template.GetString() ?? string.Empty : string.Empty; + } + } + catch (JsonException) + { + } + var ready = File.Exists(executable) && File.Exists(manifestPath) && File.Exists(templatePath) && + File.Exists(templateEntryPath) && File.Exists(templateReadmePath) && + !string.IsNullOrWhiteSpace(hostApiVersion) && !string.IsNullOrWhiteSpace(templateVersion); + return Task.FromResult(new PluginRuntimeDependencyInfo( + ready, + executable, + manifestPath, + templatePath, + hostApiVersion, + templateVersion, + ready ? null : "The bundled Tauri host, runtime manifest, or prebuilt template package is missing.")); + } + + public async Task GetBundledToolchainAsync(CancellationToken cancellationToken = default) + { + var dependency = await GetAsync(cancellationToken).ConfigureAwait(false); + return new PluginToolchainInfo( + null, + null, + null, + Path.GetDirectoryName(dependency.HostExecutable), + dependency.IsReady, + dependency.Diagnostic ?? "Prebuilt plugins use the bundled host and do not require Cargo, Node, or npm."); + } +} diff --git a/src/box-winUI/Services/RuntimeAssetService.cs b/src/box-winUI/Services/RuntimeAssetService.cs new file mode 100644 index 0000000..b3aa49a --- /dev/null +++ b/src/box-winUI/Services/RuntimeAssetService.cs @@ -0,0 +1,264 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Web.WebView2.Core; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Storage.Streams; +using YMhut.Box.Core.App; +using YMhut.Box.Core.Data; +using YMhut.Box.Core.Logging; + +namespace YMhut.Box.WinUI.Services; + +public interface IRuntimeAssetService +{ + RuntimeAssetSnapshot? Current { get; } + + Task EnsureLoadedAsync(CancellationToken cancellationToken = default); + + void MapWebViewHost(CoreWebView2 webView, string hostName, string assetDirectory); + + IReadOnlyList GetEntries(string relativeDirectory); + + bool TryGetBytes(string relativePath, out byte[] content); + + BitmapImage? CreateBitmapImage(string relativePath); +} + +public sealed record RuntimeAssetSnapshot( + string PackagePath, + string PackageHash, + int EntryCount, + long ExpandedBytes); + +public sealed class RuntimeAssetService( + AppPaths paths, + ILogService? logService = null) : IRuntimeAssetService +{ + private const string PackagePurpose = "runtime-assets"; + private readonly SemaphoreSlim _gate = new(1, 1); + private IReadOnlyDictionary _entries = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public RuntimeAssetSnapshot? Current { get; private set; } + + public async Task EnsureLoadedAsync(CancellationToken cancellationToken = default) + { + if (Current is not null) + { + return Current; + } + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (Current is not null) + { + return Current; + } + + var packagePath = ResolvePackagePath(); + if (packagePath is null) + { + throw new FileNotFoundException("随包静态资源包缺失,请使用官方安装包重新安装。", "Assets/runtime-assets.dat"); + } + + var package = await File.ReadAllBytesAsync(packagePath, cancellationToken).ConfigureAwait(false); + IReadOnlyList verified; + try + { + verified = AuthenticatedAssetPackage.ReadVerifiedEntries(package, PackagePurpose, cancellationToken); + } + catch (Exception exception) when (exception is InvalidDataException or CryptographicException) + { + throw new InvalidDataException("随包静态资源包校验失败,应用已拒绝加载可能被替换的页面或图片,请重新安装。", exception); + } + + _entries = verified.ToDictionary( + entry => entry.Path, + entry => new RuntimeAssetEntry(entry.Content, ContentTypeFor(entry.Path), entry.Sha256), + StringComparer.OrdinalIgnoreCase); + var hash = Convert.ToHexString(SHA256.HashData(package)).ToLowerInvariant(); + Current = new RuntimeAssetSnapshot( + packagePath, + hash, + _entries.Count, + _entries.Values.Sum(entry => (long)entry.Content.Length)); + if (logService is not null) + { + await logService.WriteAsync( + "Information", + "runtime-assets", + "Authenticated runtime assets loaded", + $"entries={Current.EntryCount}; bytes={Current.ExpandedBytes}; package={hash[..12]}", + cancellationToken).ConfigureAwait(false); + } + + return Current; + } + finally + { + _gate.Release(); + } + } + + public void MapWebViewHost(CoreWebView2 webView, string hostName, string assetDirectory) + { + ArgumentNullException.ThrowIfNull(webView); + if (Current is null) + { + throw new InvalidOperationException("Authenticated runtime assets have not been loaded."); + } + + var normalizedHost = hostName.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(normalizedHost) || normalizedHost.Contains('/') || normalizedHost.Contains('\\')) + { + throw new ArgumentException("Invalid WebView asset host name.", nameof(hostName)); + } + + var directory = NormalizePrefix(assetDirectory); + webView.AddWebResourceRequestedFilter($"https://{normalizedHost}/*", CoreWebView2WebResourceContext.All); + webView.WebResourceRequested += (_, args) => + { + if (!Uri.TryCreate(args.Request.Uri, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !string.Equals(uri.Host, normalizedHost, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var requestPath = Uri.UnescapeDataString(uri.AbsolutePath).TrimStart('/'); + string entryPath; + try + { + entryPath = AuthenticatedAssetPackage.NormalizeEntryPath(directory + requestPath); + } + catch (InvalidDataException) + { + args.Response = CreateResponse(webView, Array.Empty(), 400, "Bad Request", "text/plain; charset=utf-8"); + return; + } + + if (!_entries.TryGetValue(entryPath, out var entry)) + { + args.Response = CreateResponse(webView, Encoding.UTF8.GetBytes("Not found"), 404, "Not Found", "text/plain; charset=utf-8"); + return; + } + + args.Response = CreateResponse(webView, entry.Content, 200, "OK", entry.ContentType); + }; + } + + public IReadOnlyList GetEntries(string relativeDirectory) + { + var prefix = NormalizePrefix(relativeDirectory); + return _entries.Keys + .Where(path => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public bool TryGetBytes(string relativePath, out byte[] content) + { + try + { + var normalized = AuthenticatedAssetPackage.NormalizeEntryPath(relativePath); + if (_entries.TryGetValue(normalized, out var entry)) + { + content = entry.Content; + return true; + } + } + catch (InvalidDataException) + { + } + + content = []; + return false; + } + + public BitmapImage? CreateBitmapImage(string relativePath) + { + if (!TryGetBytes(relativePath, out var content)) + { + return null; + } + + var stream = CreateStream(content); + var image = new BitmapImage(); + image.SetSource(stream); + stream.Dispose(); + return image; + } + + private string? ResolvePackagePath() + { + foreach (var candidate in new[] + { + Path.Combine(AppContext.BaseDirectory, "Assets", "runtime-assets.dat"), + Path.Combine(paths.Assets, "runtime-assets.dat") + }.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } + + private static CoreWebView2WebResourceResponse CreateResponse( + CoreWebView2 webView, + byte[] content, + int status, + string reason, + string contentType) + { + var headers = $"Content-Type: {contentType}\r\nContent-Length: {content.Length}\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff"; + var stream = CreateStream(content); + return webView.Environment.CreateWebResourceResponse(stream, status, reason, headers); + } + + private static InMemoryRandomAccessStream CreateStream(byte[] content) + { + var stream = new InMemoryRandomAccessStream(); + using (var writer = new DataWriter(stream)) + { + writer.WriteBytes(content); + writer.StoreAsync().AsTask().GetAwaiter().GetResult(); + writer.DetachStream(); + } + stream.Seek(0); + return stream; + } + + private static string NormalizePrefix(string value) + { + var normalized = AuthenticatedAssetPackage.NormalizeEntryPath(value.TrimEnd('/', '\\')); + return normalized + "/"; + } + + private static string ContentTypeFor(string path) + => Path.GetExtension(path).ToLowerInvariant() switch + { + ".html" => "text/html; charset=utf-8", + ".css" => "text/css; charset=utf-8", + ".js" or ".mjs" => "text/javascript; charset=utf-8", + ".json" => "application/json; charset=utf-8", + ".svg" => "image/svg+xml", + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".ico" => "image/x-icon", + ".wasm" => "application/wasm", + ".glb" => "model/gltf-binary", + ".gltf" => "model/gltf+json", + ".woff" => "font/woff", + ".woff2" => "font/woff2", + ".ttf" => "font/ttf", + ".otf" => "font/otf", + _ => "application/octet-stream" + }; + + private sealed record RuntimeAssetEntry(byte[] Content, string ContentType, string Sha256); +} diff --git a/src/box-winUI/Services/StartupInitializationService.cs b/src/box-winUI/Services/StartupInitializationService.cs index ca9a89a..c607bdc 100644 --- a/src/box-winUI/Services/StartupInitializationService.cs +++ b/src/box-winUI/Services/StartupInitializationService.cs @@ -175,7 +175,8 @@ public sealed class StartupInitializationService( "data/sanguosha/十周年皮肤配置对应关系.xlsx"); context.Report(0.72, T("正在准备本地 JS 工具库缓存...", "Preparing local JS tool vendor cache...")); var vendor = await localToolVendorAssets.EnsureExtractedAsync(token).ConfigureAwait(false); - var vendorHealthy = vendor.Files.Count > 0 && File.Exists(vendor.ManifestPath); + var vendorHealthy = vendor.Files.Count > 0 && + vendor.Files.Any(path => path.EndsWith("/manifest.json", StringComparison.OrdinalIgnoreCase)); context.Report( 1, validation.IsHealthy && vendorHealthy diff --git a/src/box-winUI/Services/TauriPluginProcessService.cs b/src/box-winUI/Services/TauriPluginProcessService.cs index 90dee6c..350cdf5 100644 --- a/src/box-winUI/Services/TauriPluginProcessService.cs +++ b/src/box-winUI/Services/TauriPluginProcessService.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.IO.Pipes; using System.Text; using System.Text.Json; +using System.Security.Cryptography; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Windows.ApplicationModel.DataTransfer; @@ -409,6 +410,8 @@ public sealed class TauriPluginProcessService( plugin.Manifest.PermissionReasons, plugin.Manifest.Network, plugin.Manifest.Requirements, + plugin.Manifest.Tauri, + plugin.Manifest.ToolBindings, state.Enabled, Granted = state.GrantedPermissions.OrderBy(value => value).ToArray(), Fingerprints = state.PermissionPolicyFingerprints?.OrderBy(value => value.Key).ToArray(), @@ -527,7 +530,9 @@ public sealed class TauriPluginProcessService( if (response.UiAction is not null) { handled = true; - value = await ExecuteUiActionAsync(broker, response.UiAction, payload).ConfigureAwait(false); + value = response.UiAction == "externalTool.launch" + ? await LaunchBoundToolAsync(broker, payload).ConfigureAwait(false) + : await ExecuteUiActionAsync(broker, response.UiAction, payload).ConfigureAwait(false); } else if (request.Method is "output.set" or "output.append" or "output.clear") { @@ -568,6 +573,137 @@ public sealed class TauriPluginProcessService( }); } + private async Task LaunchBoundToolAsync(ExternalRuntimeBroker broker, JsonElement payload) + { + if (!settingsService.Current.PluginDeveloperMode) + { + throw new UnauthorizedAccessException("Native plugin tools require developer mode."); + } + var snapshot = await pluginHost.GetSnapshotAsync(broker.Cancellation.Token).ConfigureAwait(false); + var plugin = snapshot.Plugins.FirstOrDefault(candidate => + string.Equals(candidate.Manifest.Id, broker.PluginId, StringComparison.OrdinalIgnoreCase))?.ToLoadedPlugin() + ?? throw new InvalidOperationException("The plugin is no longer installed."); + if (!plugin.IsValid || !plugin.State.Enabled || + !PluginPermissionPolicy.IsGrantCurrent(plugin.Manifest, plugin.State, PluginPermission.ExternalRuntime) || + !PluginPermissionPolicy.IsGrantCurrent(plugin.Manifest, plugin.State, PluginPermission.ExternalTool) || + !string.Equals(plugin.State.ExternalRuntimeConfirmation, plugin.Manifest.Version, StringComparison.Ordinal)) + { + throw new UnauthorizedAccessException("The native plugin tool permission or version confirmation is not current."); + } + if (PluginRegistryService.ValidatePackage(plugin.Manifest, plugin.RootPath).Count > 0) + { + throw new InvalidDataException("The plugin package changed after it was approved."); + } + + var bindingId = ReadString(payload, "bindingId") ?? string.Empty; + var binding = (plugin.Manifest.ToolBindings ?? []).FirstOrDefault(candidate => + string.Equals(candidate.Id, bindingId, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException("The requested native tool binding is not declared."); + var supplied = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("arguments", out var values) && values.ValueKind == JsonValueKind.Object) + { + foreach (var property in values.EnumerateObject()) + { + supplied[property.Name] = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() ?? string.Empty + : property.Value.ToString(); + } + } + var validated = ValidateBindingArguments(plugin.RootPath, binding, supplied); + var executable = Path.GetFullPath(Path.Combine(plugin.RootPath, binding.Executable)); + var workingDirectory = Path.GetFullPath(Path.Combine(plugin.RootPath, binding.WorkingDirectory)); + var startInfo = new ProcessStartInfo + { + FileName = executable, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + CreateNoWindow = false + }; + foreach (var template in binding.Arguments ?? []) + { + var argument = template; + foreach (var value in validated) + { + argument = argument.Replace($"{{{value.Key}}}", value.Value, StringComparison.OrdinalIgnoreCase); + } + startInfo.ArgumentList.Add(argument); + } + var process = Process.Start(startInfo) ?? throw new InvalidOperationException("The declared native tool could not be started."); + try + { + broker.ToolJob.Add(process); + } + catch + { + try { process.Kill(entireProcessTree: true); } catch { } + process.Dispose(); + throw; + } + process.EnableRaisingEvents = true; + process.Exited += (_, _) => process.Dispose(); + if (binding.TimeoutMs > 0) + { + _ = Task.Run(async () => + { + try + { + await Task.Delay(binding.TimeoutMs, broker.Cancellation.Token).ConfigureAwait(false); + if (!process.HasExited) process.Kill(entireProcessTree: true); + } + catch + { + } + }); + } + await WriteLogAsync("Information", plugin.Manifest.Id, "Declared plugin tool started", $"binding={binding.Id}; pid={process.Id}", broker.Cancellation.Token).ConfigureAwait(false); + return new { bindingId = binding.Id, processId = process.Id }; + } + + private static IReadOnlyDictionary ValidateBindingArguments( + string pluginRoot, + PluginToolBindingSpec binding, + IReadOnlyDictionary supplied) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var parameter in binding.Parameters ?? []) + { + supplied.TryGetValue(parameter.Name, out var value); + value ??= string.Empty; + if (parameter.Required && string.IsNullOrWhiteSpace(value)) + { + throw new InvalidDataException($"A required native tool parameter is missing: {parameter.Name}"); + } + if (value.Length > parameter.MaxLength || value.Contains('\0') || value.Contains('\r') || value.Contains('\n')) + { + throw new InvalidDataException($"A native tool parameter is invalid: {parameter.Name}"); + } + switch (parameter.Kind) + { + case PluginToolArgumentKind.Integer when value.Length > 0 && !long.TryParse(value, out _): + throw new InvalidDataException($"A native tool integer parameter is invalid: {parameter.Name}"); + case PluginToolArgumentKind.Boolean when value.Length > 0 && !bool.TryParse(value, out _): + throw new InvalidDataException($"A native tool boolean parameter is invalid: {parameter.Name}"); + case PluginToolArgumentKind.Choice when value.Length > 0 && + !(parameter.Choices ?? []).Contains(value, StringComparer.OrdinalIgnoreCase): + throw new InvalidDataException($"A native tool choice parameter is outside its allowlist: {parameter.Name}"); + case PluginToolArgumentKind.FilePath when value.Length > 0: + var path = Path.GetFullPath(Path.Combine(pluginRoot, value)); + if (!PluginRegistryService.IsInside(pluginRoot, path) || !File.Exists(path)) + { + throw new InvalidDataException($"A native tool file parameter must resolve to an existing plugin file: {parameter.Name}"); + } + value = path; + break; + } + result[parameter.Name] = value; + } + if (supplied.Keys.Any(key => !result.ContainsKey(key))) + { + throw new InvalidDataException("The native tool request contains an undeclared parameter."); + } + return result; + } + private object UpdateExternalOutput(string sessionId, string method, JsonElement payload) { if (method == "output.clear") @@ -756,6 +892,7 @@ public sealed class TauriPluginProcessService( broker.Reader?.Dispose(); broker.Writer?.Dispose(); broker.Pipe.Dispose(); + broker.ToolJob.Dispose(); } catch { @@ -825,6 +962,7 @@ public sealed class TauriPluginProcessService( public NamedPipeServerStream Pipe { get; } = pipe; public CancellationTokenSource Cancellation { get; } = new(); public SemaphoreSlim WriteGate { get; } = new(1, 1); + public KillOnCloseJob ToolJob { get; } = new(); public StreamReader? Reader { get; set; } public StreamWriter? Writer { get; set; } public Task? LoopTask { get; set; } diff --git a/src/box-winUI/Services/WindowIconService.cs b/src/box-winUI/Services/WindowIconService.cs index e4fedcd..5ede2e4 100644 --- a/src/box-winUI/Services/WindowIconService.cs +++ b/src/box-winUI/Services/WindowIconService.cs @@ -1,30 +1,69 @@ -using Microsoft.UI; -using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; +using System.Drawing; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using WinRT.Interop; namespace YMhut.Box.WinUI.Services; public static class WindowIconService { + private const string IconResourceName = "YMhut.Box.WinUI.Assets.app_icon.ico"; + private const uint WmSetIcon = 0x0080; + private const int IconSmall = 0; + private const int IconBig = 1; + private const int SmCxIcon = 11; + private const int SmCyIcon = 12; + private const int SmCxSmIcon = 49; + private const int SmCySmIcon = 50; + private static readonly ConditionalWeakTable ActiveIcons = new(); + public static void ApplyAppIcon(Window? window) { - if (window is null) + if (window is null || ActiveIcons.TryGetValue(window, out _)) { return; } try { - var iconPath = ResolveIconPath(); - if (iconPath is null) + var hwnd = WindowNative.GetWindowHandle(window); + if (hwnd == 0) { return; } - var hwnd = WindowNative.GetWindowHandle(window); - var id = Win32Interop.GetWindowIdFromWindow(hwnd); - AppWindow.GetFromWindowId(id).SetIcon(iconPath); + var dpi = GetDpiForWindow(hwnd); + if (dpi == 0) + { + dpi = 96; + } + var small = LoadIcon( + Math.Max(16, GetSystemMetricsForDpi(SmCxSmIcon, dpi)), + Math.Max(16, GetSystemMetricsForDpi(SmCySmIcon, dpi))); + var large = LoadIcon( + Math.Max(32, GetSystemMetricsForDpi(SmCxIcon, dpi)), + Math.Max(32, GetSystemMetricsForDpi(SmCyIcon, dpi))); + if (small is null || large is null) + { + small?.Dispose(); + large?.Dispose(); + return; + } + + var icons = new WindowIcons(small, large); + ActiveIcons.Add(window, icons); + SendMessage(hwnd, WmSetIcon, (nint)IconSmall, small.Handle); + SendMessage(hwnd, WmSetIcon, (nint)IconBig, large.Handle); + window.Closed += (_, _) => + { + if (ActiveIcons.TryGetValue(window, out var current)) + { + ActiveIcons.Remove(window); + current.Dispose(); + } + }; } catch (Exception exception) { @@ -32,21 +71,33 @@ public static class WindowIconService } } - public static string? ResolveIconPath() + private static Icon? LoadIcon(int width, int height) { - foreach (var path in new[] + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(IconResourceName); + if (stream is null) { - Path.Combine(AppContext.BaseDirectory, "Assets", "app_icon.ico"), - Path.Combine(AppContext.BaseDirectory, "Assets", "icons", "app_icon.ico"), - Path.Combine(AppContext.BaseDirectory, "app_icon.ico") - }) - { - if (File.Exists(path)) - { - return path; - } + return null; } - return null; + using var source = new Icon(stream, width, height); + return (Icon)source.Clone(); } + + private sealed class WindowIcons(Icon small, Icon large) : IDisposable + { + public void Dispose() + { + small.Dispose(); + large.Dispose(); + } + } + + [DllImport("user32.dll")] + private static extern uint GetDpiForWindow(nint hwnd); + + [DllImport("user32.dll")] + private static extern int GetSystemMetricsForDpi(int index, uint dpi); + + [DllImport("user32.dll", EntryPoint = "SendMessageW")] + private static extern nint SendMessage(nint hwnd, uint message, nint wParam, nint lParam); } diff --git a/src/box-winUI/Services/WindowMovePerformanceHelper.cs b/src/box-winUI/Services/WindowMovePerformanceHelper.cs index 22c4d60..d613f8f 100644 --- a/src/box-winUI/Services/WindowMovePerformanceHelper.cs +++ b/src/box-winUI/Services/WindowMovePerformanceHelper.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using WinRT.Interop; @@ -15,12 +16,16 @@ public sealed class WindowMovePerformanceHelper : IDisposable private readonly Window _window; private readonly Func _backdropProvider; private readonly Action? _moveStateChanged; + private readonly DispatcherQueue _dispatcherQueue; + private readonly DispatcherQueueTimer _restoreTimer; private readonly SubclassProc _subclassProc; private readonly nuint _subclassId; private readonly nint _hwnd; private bool _subclassInstalled; private bool _disposed; private bool _inMoveLoop; + private int _nativeMoveState; + private int _dispatchQueued; private WindowMovePerformanceHelper( Window window, @@ -30,6 +35,11 @@ public sealed class WindowMovePerformanceHelper : IDisposable _window = window; _backdropProvider = backdropProvider ?? (() => "mica"); _moveStateChanged = moveStateChanged; + _dispatcherQueue = window.DispatcherQueue; + _restoreTimer = _dispatcherQueue.CreateTimer(); + _restoreTimer.Interval = TimeSpan.FromMilliseconds(120); + _restoreTimer.IsRepeating = false; + _restoreTimer.Tick += RestoreTimer_Tick; _subclassProc = WindowSubclassProc; _subclassId = (nuint)Interlocked.Increment(ref s_nextSubclassId); _hwnd = WindowNative.GetWindowHandle(window); @@ -59,9 +69,12 @@ public sealed class WindowMovePerformanceHelper : IDisposable _disposed = true; _window.Closed -= Window_Closed; + _restoreTimer.Stop(); + _restoreTimer.Tick -= RestoreTimer_Tick; if (_inMoveLoop) { - EndMoveLoop(); + _inMoveLoop = false; + _moveStateChanged?.Invoke(false); } if (_subclassInstalled && _hwnd != 0) @@ -78,13 +91,13 @@ public sealed class WindowMovePerformanceHelper : IDisposable switch (msg) { case WmEnterSizeMove: - BeginMoveLoop(); + QueueMoveState(active: true); break; case WmExitSizeMove: - EndMoveLoop(); + QueueMoveState(active: false); break; case WmNcDestroy: - Dispose(); + _dispatcherQueue.TryEnqueue(Dispose); break; } } @@ -96,13 +109,47 @@ public sealed class WindowMovePerformanceHelper : IDisposable return DefSubclassProc(hwnd, msg, wParam, lParam); } - private void BeginMoveLoop() + private void QueueMoveState(bool active) { - if (_disposed || _inMoveLoop) + Volatile.Write(ref _nativeMoveState, active ? 1 : 0); + if (Interlocked.Exchange(ref _dispatchQueued, 1) != 0) { return; } + if (!_dispatcherQueue.TryEnqueue(ApplyQueuedMoveState)) + { + Interlocked.Exchange(ref _dispatchQueued, 0); + } + } + + private void ApplyQueuedMoveState() + { + Interlocked.Exchange(ref _dispatchQueued, 0); + if (_disposed) + { + return; + } + + if (Volatile.Read(ref _nativeMoveState) != 0) + { + BeginMoveLoop(); + } + else + { + ScheduleEndMoveLoop(); + } + } + + private void BeginMoveLoop() + { + if (_disposed || _inMoveLoop) + { + _restoreTimer.Stop(); + return; + } + + _restoreTimer.Stop(); _inMoveLoop = true; _moveStateChanged?.Invoke(true); if (UsesCompositionBackdrop(_backdropProvider())) @@ -111,9 +158,21 @@ public sealed class WindowMovePerformanceHelper : IDisposable } } - private void EndMoveLoop() + private void ScheduleEndMoveLoop() { - if (!_inMoveLoop) + if (_disposed || !_inMoveLoop) + { + return; + } + + _restoreTimer.Stop(); + _restoreTimer.Start(); + } + + private void RestoreTimer_Tick(DispatcherQueueTimer sender, object args) + { + sender.Stop(); + if (_disposed || !_inMoveLoop || Volatile.Read(ref _nativeMoveState) != 0) { return; } diff --git a/src/box-winUI/Services/WindowsTrayService.cs b/src/box-winUI/Services/WindowsTrayService.cs index 621252b..751f3fb 100644 --- a/src/box-winUI/Services/WindowsTrayService.cs +++ b/src/box-winUI/Services/WindowsTrayService.cs @@ -1,4 +1,5 @@ using System.Drawing; +using System.Reflection; using System.Runtime.InteropServices; using Microsoft.UI; using Microsoft.UI.Dispatching; @@ -10,6 +11,7 @@ namespace YMhut.Box.WinUI.Services; public sealed class WindowsTrayService : ITrayService { + private const string IconResourceName = "YMhut.Box.WinUI.Assets.app_icon.ico"; private const int GwlWndProc = -4; private const uint NifMessage = 0x00000001; private const uint NifIcon = 0x00000002; @@ -194,20 +196,23 @@ public sealed class WindowsTrayService : ITrayService } } - private static Icon LoadIcon() + private Icon LoadIcon() { - foreach (var path in new[] + try { - Path.Combine(AppContext.BaseDirectory, "Assets", "app_icon.ico"), - Path.Combine(AppContext.BaseDirectory, "Assets", "icons", "app_icon.ico"), - Path.Combine(AppContext.BaseDirectory, "app_icon.ico") - }) - { - if (File.Exists(path)) + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(IconResourceName); + if (stream is not null) { - return new Icon(path); + var dpi = _hwnd == 0 ? 96u : GetDpiForWindow(_hwnd); + var size = Math.Max(16, GetSystemMetricsForDpi(49, dpi == 0 ? 96u : dpi)); + using var icon = new Icon(stream, size, size); + return (Icon)icon.Clone(); } } + catch (Exception exception) + { + CrashLog.Write(exception); + } return (Icon)SystemIcons.Application.Clone(); } @@ -291,4 +296,10 @@ public sealed class WindowsTrayService : ITrayService [DllImport("user32.dll", SetLastError = true)] private static extern bool PostMessage(nint hWnd, uint msg, nint wParam, nint lParam); + + [DllImport("user32.dll")] + private static extern uint GetDpiForWindow(nint hwnd); + + [DllImport("user32.dll")] + private static extern int GetSystemMetricsForDpi(int index, uint dpi); } diff --git a/src/box-winUI/StartupSplashWindow.cs b/src/box-winUI/StartupSplashWindow.cs index 0370d55..5ec22a6 100644 --- a/src/box-winUI/StartupSplashWindow.cs +++ b/src/box-winUI/StartupSplashWindow.cs @@ -27,6 +27,7 @@ public sealed class StartupSplashWindow : Window private const string SplashHostName = "startup-splash.ymhut.local"; private readonly WebView2EnvironmentFactory _webViewEnvironmentFactory = AppServices.GetRequiredService(); + private readonly IRuntimeAssetService _runtimeAssets = AppServices.GetRequiredService(); private readonly ISettingsService _settingsService = AppServices.GetRequiredService(); private readonly WindowChromeService _windowChromeService = AppServices.GetRequiredService(); private readonly WebView2 _webView = new() @@ -203,7 +204,7 @@ public sealed class StartupSplashWindow : Window BorderThickness = new Thickness(1), Child = new Image { - Source = new BitmapImage(new Uri("ms-appx:///Assets/icons/app_icon.png")), + Source = _runtimeAssets.CreateBitmapImage("icons/app_icon.png"), Width = 34, Height = 34, HorizontalAlignment = HorizontalAlignment.Center, @@ -312,13 +313,6 @@ public sealed class StartupSplashWindow : Window _webInitializationStarted = true; try { - var resourceFolder = Path.Combine(AppContext.BaseDirectory, "Assets", "startup-splash"); - if (!Directory.Exists(resourceFolder)) - { - ActivateNativeFallback(); - return; - } - var environment = await _webViewEnvironmentFactory.CreateAsync("StartupSplash").ConfigureAwait(true); await _webView.EnsureCoreWebView2Async(environment); _webView.DefaultBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); @@ -328,10 +322,7 @@ public sealed class StartupSplashWindow : Window _webView.CoreWebView2.Settings.IsStatusBarEnabled = false; _webView.CoreWebView2.Settings.IsZoomControlEnabled = false; _webView.CoreWebView2.WebMessageReceived += WebView_WebMessageReceived; - _webView.CoreWebView2.SetVirtualHostNameToFolderMapping( - SplashHostName, - resourceFolder, - CoreWebView2HostResourceAccessKind.Allow); + _runtimeAssets.MapWebViewHost(_webView.CoreWebView2, SplashHostName, "startup-splash"); _webView.Source = new Uri($"https://{SplashHostName}/index.html"); } catch (Exception exception) diff --git a/src/box-winUI/ViewModels/ToolCardViewModel.cs b/src/box-winUI/ViewModels/ToolCardViewModel.cs index 631a79a..92d8f3f 100644 --- a/src/box-winUI/ViewModels/ToolCardViewModel.cs +++ b/src/box-winUI/ViewModels/ToolCardViewModel.cs @@ -43,13 +43,15 @@ public sealed class ToolCardViewModel : INotifyPropertyChanged public string CapabilityLabel => AppLocalizer.Capability(Metadata.OfflineCapable); - public string SourceLabel => Module switch - { - ExternalToolModule => AppLocalizer.T("外部工具", "External"), - BuiltinReferenceToolModule => AppLocalizer.T("YMhut 内置", "YMhut built-in"), - PluginToolModule => AppLocalizer.T("插件工具", "Plugin"), - _ => AppLocalizer.T("原生工具", "Native") - }; + public string SourceLabel => !string.IsNullOrWhiteSpace(Metadata.Provider) + ? Metadata.Provider + : Module switch + { + ExternalToolModule => AppLocalizer.T("外部工具", "External"), + BuiltinReferenceToolModule => AppLocalizer.T("YMhut 内置", "YMhut built-in"), + PluginToolModule => AppLocalizer.T("插件工具", "Plugin"), + _ => AppLocalizer.T("原生工具", "Native") + }; public string SafetyLabel => Module switch { diff --git a/src/box-winUI/ViewModels/Tools/PearApiToolViewModel.cs b/src/box-winUI/ViewModels/Tools/PearApiToolViewModel.cs new file mode 100644 index 0000000..1d52b9c --- /dev/null +++ b/src/box-winUI/ViewModels/Tools/PearApiToolViewModel.cs @@ -0,0 +1,14 @@ +using YMhut.Box.Core.Api; +using YMhut.Box.Core.Tools; + +namespace YMhut.Box.WinUI.ViewModels.Tools; + +public abstract class PearApiToolViewModel(IToolModule module, PearApiToolDefinition definition) + : AdaptiveToolViewModel(module) +{ + public PearApiToolDefinition Definition { get; } = definition; + + public Dictionary Values { get; } = new(StringComparer.OrdinalIgnoreCase); + + public string? SelectedFilePath { get; set; } +} diff --git a/src/box-winUI/Views/AboutPage.cs b/src/box-winUI/Views/AboutPage.cs index 37235ba..f5e31d3 100644 --- a/src/box-winUI/Views/AboutPage.cs +++ b/src/box-winUI/Views/AboutPage.cs @@ -18,6 +18,7 @@ namespace YMhut.Box.WinUI.Views; public sealed class AboutPage : Page { private readonly IAppVersionService _versionService = AppServices.GetRequiredService(); + private readonly IRuntimeAssetService _runtimeAssets = AppServices.GetRequiredService(); private readonly IAppInstallerUpdateService _updateService = AppServices.GetRequiredService(); private readonly IOpenSourceReferenceService _referenceService = AppServices.GetRequiredService(); private readonly ISettingsService _settingsService = AppServices.GetRequiredService(); @@ -169,7 +170,7 @@ public sealed class AboutPage : Page Background = ModernUi.Transparent, Child = new Image { - Source = new BitmapImage(new Uri("ms-appx:///Assets/icons/app_icon.png")), + Source = _runtimeAssets.CreateBitmapImage("icons/app_icon.png"), Width = 48, Height = 48 } @@ -207,7 +208,7 @@ public sealed class AboutPage : Page { new Image { - Source = new BitmapImage(new Uri("ms-appx:///Assets/images/author_avatar.png")), + Source = _runtimeAssets.CreateBitmapImage("images/author_avatar.png"), Width = 48, Height = 48 }, @@ -240,13 +241,7 @@ public sealed class AboutPage : Page Children = { _updateProgress, - new Image - { - Source = new SvgImageSource(new Uri("ms-appx:///Assets/download.svg")), - Width = 18, - Height = 18, - Stretch = Microsoft.UI.Xaml.Media.Stretch.Uniform - }, + new SymbolIcon { Symbol = Symbol.Download, Width = 18, Height = 18 }, _updateButtonText } }; diff --git a/src/box-winUI/Views/HomePage.cs b/src/box-winUI/Views/HomePage.cs index 1975e4a..80a987c 100644 --- a/src/box-winUI/Views/HomePage.cs +++ b/src/box-winUI/Views/HomePage.cs @@ -28,6 +28,7 @@ public sealed class HomePage : Page private readonly Action _openServiceStatus; private readonly Action _openLogs; private readonly WebView2EnvironmentFactory _webViewEnvironmentFactory = AppServices.GetRequiredService(); + private readonly IRuntimeAssetService _runtimeAssets = AppServices.GetRequiredService(); private readonly ISolarEphemerisService _solarEphemerisService = AppServices.GetRequiredService(); private readonly ISettingsService _settingsService = AppServices.GetRequiredService(); private readonly IAppInstallerUpdateService _updateService = AppServices.GetRequiredService(); @@ -865,12 +866,6 @@ public sealed class HomePage : Page { try { - var resourceFolder = Path.Combine(AppContext.BaseDirectory, "Assets", "home-globe"); - if (!Directory.Exists(resourceFolder)) - { - throw new DirectoryNotFoundException(resourceFolder); - } - var environment = await _webViewEnvironmentFactory.CreateAsync("HomeGlobe"); await _globeView.EnsureCoreWebView2Async(environment); _globeView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; @@ -878,10 +873,7 @@ public sealed class HomePage : Page _globeView.CoreWebView2.Settings.AreHostObjectsAllowed = false; _globeView.CoreWebView2.Settings.IsStatusBarEnabled = false; _globeView.CoreWebView2.Settings.IsZoomControlEnabled = false; - _globeView.CoreWebView2.SetVirtualHostNameToFolderMapping( - GlobeHostName, - resourceFolder, - CoreWebView2HostResourceAccessKind.Allow); + _runtimeAssets.MapWebViewHost(_globeView.CoreWebView2, GlobeHostName, "home-globe"); _ephemerisTask = _solarEphemerisService.GetCurrentAsync(); _preciseEphemerisTask = _solarEphemerisService.GetPreciseCurrentAsync(); _globeView.CoreWebView2.NavigationCompleted += GlobeView_NavigationCompleted; diff --git a/src/box-winUI/Views/OnlineToolsPage.cs b/src/box-winUI/Views/OnlineToolsPage.cs new file mode 100644 index 0000000..4564c61 --- /dev/null +++ b/src/box-winUI/Views/OnlineToolsPage.cs @@ -0,0 +1,188 @@ +using Microsoft.UI.Text; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using YMhut.Box.Core.Api; +using YMhut.Box.Core.Tools; +using YMhut.Box.WinUI.Services; + +namespace YMhut.Box.WinUI.Views; + +public sealed class OnlineToolsPage : Page +{ + private static readonly string[] Categories = ["全部", "AI", "生活", "网络", "娱乐", "学习", "图像", "数据", "开发", "实用"]; + private readonly ToolCatalog _catalog = AppServices.GetRequiredService(); + private readonly Action _openTool; + private readonly AutoSuggestBox _search = new() + { + PlaceholderText = "搜索联网工具", + QueryIcon = new SymbolIcon(Symbol.Find), + MinWidth = 220, + MaxWidth = 420 + }; + private readonly ComboBox _category = new() + { + Header = "分类", + MinWidth = 150, + ItemsSource = Categories, + SelectedIndex = 0 + }; + private readonly StackPanel _items = new() { Spacing = 1 }; + private readonly TextBlock _count = ModernUi.Text(string.Empty, 12, FontWeights.SemiBold, ModernUi.TextSecondary, maxLines: 1); + + public OnlineToolsPage(Action openTool) + { + _openTool = openTool; + Background = ModernUi.AppBackground; + Content = BuildContent(); + _search.TextChanged += (_, _) => Refresh(); + _search.QuerySubmitted += (_, _) => Refresh(); + _category.SelectionChanged += (_, _) => Refresh(); + Loaded += (_, _) => Refresh(); + } + + private UIElement BuildContent() + { + var toolbar = new Grid { ColumnSpacing = 12 }; + toolbar.ColumnDefinitions.Add(new ColumnDefinition()); + toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + toolbar.Children.Add(_search); + Grid.SetColumn(_category, 1); + toolbar.Children.Add(_category); + + var title = new Grid { ColumnSpacing = 12 }; + title.ColumnDefinitions.Add(new ColumnDefinition()); + title.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + title.Children.Add(new StackPanel + { + Spacing = 3, + Children = + { + ModernUi.Text("联网工具", 24, FontWeights.SemiBold, maxLines: 1), + ModernUi.Text("固定 74 项 PearAPI 文档目录;只调用免 Key 允许路径,接口变更时不会转用付费服务。", 13, foreground: ModernUi.TextSecondary, maxLines: 3) + } + }); + Grid.SetColumn(_count, 1); + _count.VerticalAlignment = VerticalAlignment.Bottom; + title.Children.Add(_count); + + var listScroll = new ScrollViewer + { + Content = _items, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + VerticalScrollMode = ScrollMode.Enabled + }; + var root = new Grid { Padding = new Thickness(18), RowSpacing = 14 }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition()); + root.Children.Add(title); + Grid.SetRow(toolbar, 1); + root.Children.Add(toolbar); + var notice = new InfoBar + { + IsOpen = true, + IsClosable = false, + Severity = InfoBarSeverity.Informational, + Title = "按需联网", + Message = "公共无输入请求可在内存短时缓存;文本、网址、金融查询和上传内容不持久化缓存。文件只会在二次确认后发送。" + }; + Grid.SetRow(notice, 2); + root.Children.Add(notice); + Grid.SetRow(listScroll, 3); + root.Children.Add(listScroll); + return root; + } + + private void Refresh() + { + var query = (_search.Text ?? string.Empty).Trim(); + var category = _category.SelectedItem?.ToString() ?? "全部"; + var definitions = PearApiCatalog.All + .Where(definition => category == "全部" || definition.Category == category) + .Where(definition => query.Length == 0 || + definition.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || + definition.Description.Contains(query, StringComparison.OrdinalIgnoreCase) || + definition.Category.Contains(query, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(definition => definition.IsAvailableWithoutKey) + .ThenBy(definition => definition.Category, StringComparer.CurrentCulture) + .ThenBy(definition => definition.Name, StringComparer.CurrentCulture) + .ToArray(); + _items.Children.Clear(); + foreach (var definition in definitions) + { + _items.Children.Add(BuildRow(definition)); + } + _count.Text = $"{definitions.Length} / {PearApiCatalog.All.Count}"; + if (definitions.Length == 0) + { + _items.Children.Add(new InfoBar { IsOpen = true, IsClosable = false, Message = "没有匹配的联网工具。" }); + } + } + + private UIElement BuildRow(PearApiToolDefinition definition) + { + var grid = new Grid { Padding = new Thickness(12, 10, 12, 10), ColumnSpacing = 12, MinHeight = 68 }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition()); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.Children.Add(ModernUi.IconTile(CategoryGlyph(definition.Category), 38, ModernUi.SurfaceAlt, ModernUi.Accent, 16)); + var body = new StackPanel + { + Spacing = 3, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + ModernUi.Text(definition.Name, 14, FontWeights.SemiBold, maxLines: 1), + ModernUi.Text(definition.Description, 12, foreground: ModernUi.TextSecondary, maxLines: 2) + } + }; + Grid.SetColumn(body, 1); + grid.Children.Add(body); + var status = ModernUi.Badge( + definition.IsAvailableWithoutKey ? definition.Category : "需鉴权 / 已禁用", + definition.IsAvailableWithoutKey ? ModernUi.Accent : ModernUi.TextSecondary, + definition.IsAvailableWithoutKey ? ModernUi.AccentSoft : ModernUi.SurfaceAlt); + status.VerticalAlignment = VerticalAlignment.Center; + Grid.SetColumn(status, 2); + grid.Children.Add(status); + + var button = new Button + { + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch, + Background = ModernUi.Transparent, + BorderBrush = ModernUi.Stroke, + BorderThickness = new Thickness(0, 0, 0, 1), + CornerRadius = new CornerRadius(0), + Padding = new Thickness(0), + Content = grid, + UseSystemFocusVisuals = true + }; + ModernUi.ApplyHoverResources(button, ModernUi.HoverSurface, ModernUi.StrokeStrong); + button.Click += (_, _) => + { + var module = _catalog.GetById(definition.ToolId); + if (module is not null) _openTool(module); + }; + AutomationProperties.SetName(button, definition.Name); + AutomationProperties.SetHelpText(button, definition.Description); + ToolTipService.SetToolTip(button, definition.IsAvailableWithoutKey ? definition.Description : definition.AvailabilityNote); + return button; + } + + private static string CategoryGlyph(string category) => category switch + { + "AI" => "\uE99A", + "生活" => "\uE80F", + "网络" => "\uE774", + "娱乐" => "\uE8D6", + "学习" => "\uE82D", + "图像" => "\uEB9F", + "数据" => "\uE9D9", + "开发" => "\uE943", + _ => "\uE8EF" + }; +} diff --git a/src/box-winUI/Views/PluginDocsPage.cs b/src/box-winUI/Views/PluginDocsPage.cs index 99100d4..3ba64a4 100644 --- a/src/box-winUI/Views/PluginDocsPage.cs +++ b/src/box-winUI/Views/PluginDocsPage.cs @@ -2,12 +2,15 @@ using Microsoft.UI.Text; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; +using YMhut.Box.Core.Settings; +using YMhut.Box.WinUI.Services; namespace YMhut.Box.WinUI.Views; public sealed class PluginDocsPage : Page { private readonly Action? _goBack; + private readonly ISettingsService _settings = AppServices.GetRequiredService(); public PluginDocsPage(Action? goBack = null) { @@ -54,15 +57,24 @@ entry:默认 HTML 入口,相对插件目录。 permissions:希望使用的能力列表;声明不等于授权,用户仍需在插件页开启。 surfaces:挂载入口,当前支持 ToolboxTool 和 NavPage。 resources:插件资源白名单,路径必须留在插件目录内。 +tauri:预构建目录、固定宿主 API 和模板版本;不能声明私有 Tauri 宿主程序。 +toolBindings:受控第三方工具清单;每个 EXE/DLL 都必须提供 SHA-256,参数必须使用声明的类型化槽位。 builtIn:仅内置示例使用,第三方插件不要设置。 """)); - docs.Children.Add(Section("3. surface 与运行入口", """ + docs.Children.Add(Section("3. Tauri 预构建与 .ymplugin", """ +YMhut Box 随包提供固定 Tauri 宿主、运行时清单和模板,不在用户电脑上调用 Node、npm、Cargo 或 Rust 编译插件。 + +Tauri 插件应把生产构建产物放入 dist/,入口通常为 dist/index.html。插件页可以创建预构建示例、导入文件夹或 .ymplugin 包,并将验证通过的插件导出为 .ymplugin。 + +.ymplugin 是带版本、插件 ID、manifest SHA-256 和文件清单的本地包。导入时会在隔离临时目录解包,并拒绝越界路径、链接、损坏元数据和未声明原生文件。 +""")); + docs.Children.Add(Section("4. surface 与运行入口", """ ToolboxTool:挂载到工具箱,工具 ID 形如 plugin::。 NavPage:显示在插件页,可作为插件主页面或控制台。 surface.entry 可覆盖 manifest.entry;未填写时使用 manifest.entry。插件页面加载在独立 WebView 用户数据目录中,和安全浏览器、普通工具 WebView 分开。 """)); - docs.Children.Add(Section("4. 权限模型", """ + docs.Children.Add(Section("5. 权限模型", """ manifest v3 要求每项权限填写 permissionReasons。security.requiredPermissions 未授权时禁止启用,其余权限未授权时 Bridge 返回稳定错误码。 Input:读取/写入插件输入。 @@ -76,8 +88,10 @@ RunTool:调用允许的内置工具。 OpenExternal:仅打开 openExternalOrigins 声明的 HTTPS 来源,默认进入安全浏览器。 OpenSystemBrowser:打开已声明 HTTPS 来源,每次调用都由宿主确认。 NetworkDiagnostics:读取本机网络诊断、ping、DNS、trace route 等宿主诊断能力。 +ExternalRuntime:在固定随包 Tauri 宿主中呈现预构建页面。 +ExternalTool:按清单、哈希和参数规则启动绑定工具;只能在开发者模式和当前版本确认有效时使用。 """)); - docs.Children.Add(Section("5. JS Bridge", """ + docs.Children.Add(Section("6. JS Bridge", """ window.ymhut.input.get() window.ymhut.input.set(value) window.ymhut.output.set(value) @@ -100,26 +114,34 @@ window.ymhut.clipboard.writeText(text) window.ymhut.file.openPicker() window.ymhut.file.savePicker(name, value) window.ymhut.tool.run(toolId, input) +window.ymhut.externalTool.launch(bindingId, arguments) window.ymhut.openExternal(url, { target?: "safe" | "system" }) openExternal 默认打开应用内安全浏览器。显式传入 { target: "system" } 需要 OpenSystemBrowser 权限,且每次调用都由宿主确认。 """)); - docs.Children.Add(Section("6. 独立窗口、输出区与遮挡", """ + docs.Children.Add(Section("7. 第三方工具绑定", """ +绑定工具仅支持插件目录中的已声明 EXE,以及它依赖的已声明 DLL。清单必须列出每个文件的 SHA-256;工具文件变化后原确认立即失效。 + +宿主使用 UseShellExecute=false 启动,不允许提权、安装器、驱动、服务、脚本或任意命令行。参数通过 ArgumentList 传递,动态值必须匹配 manifest 中声明的 Text、Integer、Boolean、Choice 或插件内 FilePath 类型。 + +工具进程属于当前插件会话 Job Object。插件关闭、权限撤销、清单变化或主程序退出时,相关进程会被终止。 +""")); + docs.Children.Add(Section("8. 独立窗口、输出区与遮挡", """ 插件可以作为工具箱工具或插件页运行。需要脱离主窗口时,应使用宿主提供的独立窗口入口,而不是自行弹出浏览器或远程窗口。 插件的主要 UI 应在 WebView 内容区内完成。window.ymhut.output.* 写入的是宿主输出区,适合报告、日志摘要、可复制结果,不适合持续覆盖主操作面板。 避免使用全屏 fixed 遮罩、超高 z-index、透明点击层或模拟系统标题栏。确实需要模态交互时,应提供明显关闭按钮,并保证键盘焦点能返回主内容。 """)); - docs.Children.Add(Section("7. 安全边界", """ + docs.Children.Add(Section("9. 安全边界", """ 每个插件 surface 使用独立虚拟 HTTPS 原点和独立 WebView2 数据目录,不使用 file://。跨原点导航、新窗口、下载、iframe 与浏览器敏感权限会被拒绝。 插件不能覆盖内置工具 ID、应用图标、开发者信息、关于页核心身份或内置 Assets 路径。 插件不能通过相对路径逃逸插件目录,resources、entry 和 surface.entry 都会被校验。 文件能力只通过系统选择器提供,不开放静默任意路径读写。 页面直接 fetch/WebSocket 与宿主 fetch 使用同一精确来源白名单;HTTP、localhost、局域网、私网和越界重定向会被拒绝。 -旧清单以 LegacyWebOnly 运行,本地 HTML/CSS/JS 可用,Bridge、远程连接和外接运行时关闭。Shell/Script 和插件自带原生程序不受支持。 +旧清单以 LegacyWebOnly 运行,本地 HTML/CSS/JS 可用,Bridge、远程连接和外接运行时关闭。Shell/Script 仍不受支持;原生程序只能通过严格的 toolBindings 使用。 """)); - docs.Children.Add(Section("8. 常见问题", """ + docs.Children.Add(Section("10. 常见问题", """ 加载失败:检查 ymhut.plugin.json、README、entry 文件是否存在,路径是否在插件目录内。 权限拒绝:确认 manifest 声明了权限,并在插件页为该插件授权。 外链打不开:只支持清单 openExternalOrigins 中的绝对 HTTPS 来源;系统浏览器还需要逐次确认。 @@ -127,7 +149,7 @@ openExternal 默认打开应用内安全浏览器。显式传入 { target: "syst 独立窗口异常:确认插件入口没有依赖主窗口 DOM,也不要调用浏览器弹窗 API 创建 Edge/系统私密窗口。 网络结果为空:Http 或 NetworkDiagnostics 权限可能未授予,或远程端点不可用,插件应给出降级提示。 """)); - docs.Children.Add(Section("9. 给 AI/自动化实现者的约束", """ + docs.Children.Add(Section("11. 给 AI/自动化实现者的约束", """ 生成插件时请优先创建最小可运行包:manifest、README、index.html、style.css、main.js。 不要请求不需要的权限;每个权限都应在 README 中说明用途。 不要下载远程脚本作为运行依赖;可用远程 API 获取数据,但 UI 和核心逻辑应随插件包本地发布。 @@ -135,6 +157,20 @@ openExternal 默认打开应用内安全浏览器。显式传入 { target: "syst 链接跳转默认使用 window.ymhut.openExternal(url),系统浏览器仅作为显式动作。 为窄窗口、独立窗口和无网络状态提供清晰布局与空态。 """)); + var resetNotice = ModernUi.PillButton("重新显示第三方插件提示", "\uE72C", async () => + { + await _settings.UpdateAsync(settings => settings.PluginNoticeVersion = string.Empty); + ToastService.Show("下次进入插件页时会重新显示提示。", ToastKind.Success, TimeSpan.FromSeconds(3)); + }); + docs.Children.Add(Section("第三方内容提示", new StackPanel + { + Spacing = 10, + Children = + { + ModernUi.Text("插件内容和绑定工具由第三方提供。你可以随时重置提示状态,再次查看当前免责说明。", 13, foreground: ModernUi.TextSecondary, maxLines: 4), + resetNotice + } + })); var scroll = new ScrollViewer { @@ -167,4 +203,17 @@ openExternal 默认打开应用内安全浏览器。显式传入 { target: "syst } }, new Thickness(16), radius: ModernUi.PanelRadius); } + + private static Border Section(string title, UIElement body) + { + return ModernUi.Card(new StackPanel + { + Spacing = 8, + Children = + { + ModernUi.Text(title, 20, FontWeights.SemiBold), + body + } + }, new Thickness(16), radius: ModernUi.PanelRadius); + } } diff --git a/src/box-winUI/Views/PluginPage.cs b/src/box-winUI/Views/PluginPage.cs index 140e42c..e54f2d3 100644 --- a/src/box-winUI/Views/PluginPage.cs +++ b/src/box-winUI/Views/PluginPage.cs @@ -5,6 +5,7 @@ using Windows.Storage.Pickers; using WinRT.Interop; using YMhut.Box.Core.Logging; using YMhut.Box.Core.Plugins; +using YMhut.Box.Core.Plugins.Runtime; using YMhut.Box.Core.Settings; using YMhut.Box.WinUI.Services; @@ -20,6 +21,7 @@ public sealed class PluginPage : Page private readonly IPluginPackageService _pluginPackages = AppServices.GetRequiredService(); private readonly IBuiltInPluginInstallerService _builtInInstaller = AppServices.GetRequiredService(); private readonly ISettingsService _settingsService = AppServices.GetRequiredService(); + private readonly IPluginRuntimeDependencyService _runtimeDependencies = AppServices.GetRequiredService(); private readonly Action _openDocs; private readonly Action _openSurface; private readonly Func? _catalogChanged; @@ -30,6 +32,7 @@ public sealed class PluginPage : Page }; private readonly TextBlock _statusText = ModernUi.Text("正在加载插件...", 13, foreground: ModernUi.TextSecondary); private readonly TextBlock _rootText = ModernUi.Text("插件根目录:等待插件宿主启动", 14, foreground: ModernUi.TextSecondary, maxLines: 2); + private readonly TextBlock _runtimeText = ModernUi.Text("正在检查 Tauri 随包运行时...", 12, foreground: ModernUi.TextSecondary, maxLines: 2); private bool _scanInProgress; @@ -49,7 +52,11 @@ public sealed class PluginPage : Page Content = BuildContent(); _pluginHost.SnapshotChanged += PluginHost_SnapshotChanged; _pluginHost.StatusChanged += PluginHost_StatusChanged; - Loaded += async (_, _) => await LoadAsync(); + Loaded += async (_, _) => + { + await ShowPluginNoticeAsync(); + await LoadAsync(); + }; Unloaded += (_, _) => { _pluginHost.SnapshotChanged -= PluginHost_SnapshotChanged; @@ -72,13 +79,14 @@ public sealed class PluginPage : Page var scan = ModernUi.PillButton(AppLocalizer.T("扫描插件", "Scan plugins"), "\uE72C", async () => await ScanAsync()); var create = ModernUi.PillButton(AppLocalizer.T("新建示例", "New sample"), "\uE710", async () => await CreateTemplateAsync()); var import = ModernUi.PillButton(AppLocalizer.T("导入文件夹", "Import folder"), "\uE8B5", async () => await ImportFolderAsync()); + var importPackage = ModernUi.PillButton(AppLocalizer.T("导入插件包", "Import package"), "\uE7C3", async () => await ImportPackageAsync()); var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Top, - Children = { create, import, scan, help } + Children = { create, import, importPackage, scan, help } }; root.Children.Add(ModernUi.PageHeader( @@ -90,6 +98,7 @@ public sealed class PluginPage : Page var body = new StackPanel { Spacing = 12 }; body.Children.Add(_statusText); + body.Children.Add(_runtimeText); body.Children.Add(_pluginGrid); var scroll = new ScrollViewer { @@ -105,6 +114,11 @@ public sealed class PluginPage : Page private async Task LoadAsync() { + var runtime = await _runtimeDependencies.GetAsync(); + _runtimeText.Text = runtime.IsReady + ? $"Tauri 随包宿主已就绪 · Host API {runtime.HostApiVersion} · 模板 {runtime.TemplateVersion} · 无需系统 Node/Rust" + : $"Tauri 随包宿主不可用:{runtime.Diagnostic}"; + _runtimeText.Foreground = runtime.IsReady ? ModernUi.TextSecondary : ModernUi.Danger; var cached = _pluginHost.CurrentSnapshot; if (cached is not null) { @@ -130,13 +144,11 @@ public sealed class PluginPage : Page catch (Exception exception) { var safe = AppLocalizer.SanitizeSensitiveText(exception.Message, 180); - _statusText.Text = cached is null - ? $"插件宿主启动或读取失败:{safe}" - : $"插件宿主暂时不可用,已保留上次扫描结果:{safe}"; - if (cached is null) - { - _pluginGrid.Children.Add(BuildRetryCard()); - } + var empty = _pluginHost.InvalidateSnapshot(cached?.PluginsRoot); + _pluginGrid.Children.Clear(); + _pluginGrid.Children.Add(BuildRetryCard()); + _statusText.Text = $"插件宿主启动或读取失败,已清除过期显示:{safe}"; + await NotifyCatalogChangedAsync(empty); return; } @@ -170,13 +182,11 @@ public sealed class PluginPage : Page catch (Exception exception) { var safe = AppLocalizer.SanitizeSensitiveText(exception.Message, 180); - _statusText.Text = previous is null - ? AppLocalizer.T($"插件扫描失败:{safe}", $"Plugin scan failed: {safe}") - : AppLocalizer.T($"插件扫描失败,已保留上次结果:{safe}", $"Plugin scan failed; keeping the previous result: {safe}"); - if (previous is null) - { - _pluginGrid.Children.Add(BuildRetryCard()); - } + var empty = _pluginHost.InvalidateSnapshot(previous?.PluginsRoot); + _pluginGrid.Children.Clear(); + _pluginGrid.Children.Add(BuildRetryCard()); + _statusText.Text = AppLocalizer.T($"插件扫描失败,已清除过期显示:{safe}", $"Plugin scan failed; stale entries were cleared: {safe}"); + await NotifyCatalogChangedAsync(empty); await _logService.WriteAsync("Error", "plugin", "Plugin scan failed", safe); return; @@ -198,10 +208,13 @@ public sealed class PluginPage : Page { _pluginGrid.Children.Clear(); _rootText.Text = $"本地插件目录:{snapshot.PluginsRoot}"; - var plugins = snapshot.Plugins.Select(plugin => plugin.ToLoadedPlugin()).ToArray(); + var invalidCount = snapshot.Plugins.Count(plugin => !plugin.IsValid); + var plugins = snapshot.Plugins.Where(plugin => plugin.IsValid).Select(plugin => plugin.ToLoadedPlugin()).ToArray(); _statusText.Text = plugins.Length == 0 - ? "还没有安装本地插件。创建插件目录并放入 ymhut.plugin.json 后点击刷新。" - : $"已扫描 {plugins.Length} 个插件 · 插件宿主运行中 · {snapshot.LoadedAt:HH:mm:ss}"; + ? invalidCount == 0 + ? "还没有安装有效的本地插件。可以创建示例或导入 .ymplugin 包。" + : $"未发现有效插件,已从页面和工具目录移除 {invalidCount} 个损坏条目;详细原因已写入日志。" + : $"已扫描 {plugins.Length} 个有效插件{(invalidCount > 0 ? $" · 忽略 {invalidCount} 个损坏条目" : string.Empty)} · {snapshot.LoadedAt:HH:mm:ss}"; foreach (var plugin in plugins) { @@ -239,9 +252,11 @@ public sealed class PluginPage : Page } else if (status == PluginHostStatus.Failed) { - _statusText.Text = _pluginHost.CurrentSnapshot is null - ? "插件宿主进程异常,可点击刷新重试。" - : "插件宿主进程异常,已保留上次扫描结果,可点击刷新重试。"; + var empty = _pluginHost.InvalidateSnapshot(_pluginHost.CurrentSnapshot?.PluginsRoot); + _pluginGrid.Children.Clear(); + _pluginGrid.Children.Add(BuildRetryCard()); + _statusText.Text = "插件宿主进程异常,已清除过期插件显示,可点击刷新重试。"; + _ = NotifyCatalogChangedAsync(empty); } }); } @@ -340,6 +355,7 @@ public sealed class PluginPage : Page }; content.Children.Add(BuildStateSummary(plugin)); content.Children.Add(BuildSecuritySummary(plugin)); + content.Children.Add(BuildToolBindingsPanel(plugin)); content.Children.Add(BuildPermissionPanel(plugin)); content.Children.Add(BuildSurfacePanel(plugin)); content.Children.Add(BuildLocalManagementPanel(plugin)); @@ -388,6 +404,30 @@ public sealed class PluginPage : Page }); } + private static UIElement BuildToolBindingsPanel(LoadedPlugin plugin) + { + var bindings = plugin.Manifest.ToolBindings ?? []; + if (bindings.Count == 0) + { + return Section("绑定工具", ModernUi.Text("此插件未绑定第三方本地工具。", 13, foreground: ModernUi.TextSecondary)); + } + var panel = new StackPanel { Spacing = 8 }; + foreach (var binding in bindings) + { + panel.Children.Add(ModernUi.Card(new StackPanel + { + Spacing = 4, + Children = + { + ModernUi.Text(binding.Name, 14, FontWeights.SemiBold, maxLines: 1), + ModernUi.Text($"ID:{binding.Id} · {binding.Architecture} · {binding.Files.Count} 个哈希文件", 12, foreground: ModernUi.TextSecondary, maxLines: 2), + ModernUi.Text($"入口:{binding.Executable} · 参数槽:{binding.Parameters?.Count ?? 0}", 12, foreground: ModernUi.TextSecondary, maxLines: 2) + } + }, new Thickness(10), radius: 6, background: ModernUi.SurfaceAlt)); + } + return Section("绑定工具", panel); + } + private UIElement BuildLocalManagementPanel(LoadedPlugin plugin) { var clear = ModernUi.PillButton("清除插件数据", "\uE74D", async () => await ClearPluginDataAsync(plugin)); @@ -409,6 +449,7 @@ public sealed class PluginPage : Page confirm.IsEnabled = _settingsService.Current.PluginDeveloperMode && PluginPermissionPolicy.IsGrantCurrent(plugin.Manifest, plugin.State, PluginPermission.ExternalRuntime); actions.Children.Add(confirm); + actions.Children.Add(ModernUi.PillButton("导出 .ymplugin", "\uEDE1", async () => await ExportPackageAsync(plugin))); } return Section("本地管理", actions); } @@ -424,7 +465,8 @@ public sealed class PluginPage : Page Items = { "零权限 Web 示例", - "Bridge / 精确网络示例" + "Bridge / 精确网络示例", + "Tauri 预构建示例" } }; var dialog = new ContentDialog @@ -451,7 +493,12 @@ public sealed class PluginPage : Page root, id.Text, name.Text, - kind.SelectedIndex == 0 ? PluginTemplateKind.ZeroPermissionWeb : PluginTemplateKind.BridgeNetwork); + kind.SelectedIndex switch + { + 0 => PluginTemplateKind.ZeroPermissionWeb, + 1 => PluginTemplateKind.BridgeNetwork, + _ => PluginTemplateKind.TauriPrebuilt + }); _statusText.Text = result.Message; if (result.Succeeded) { @@ -482,6 +529,32 @@ public sealed class PluginPage : Page } } + private async Task ImportPackageAsync() + { + if (App.CurrentWindow is null) return; + var picker = new FileOpenPicker(); + picker.FileTypeFilter.Add(".ymplugin"); + InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(App.CurrentWindow)); + var file = await picker.PickSingleFileAsync(); + var root = _pluginHost.CurrentSnapshot?.PluginsRoot; + if (file is null || string.IsNullOrWhiteSpace(root)) return; + var result = await _pluginPackages.ImportPackageAsync(file.Path, root); + _statusText.Text = result.Message; + if (result.Succeeded) await ScanAsync(); + } + + private async Task ExportPackageAsync(LoadedPlugin plugin) + { + if (App.CurrentWindow is null) return; + var picker = new FileSavePicker { SuggestedFileName = $"{PluginIds.Normalize(plugin.Manifest.Id)}-{plugin.Manifest.Version}" }; + picker.FileTypeChoices.Add("YMhut 插件包", [".ymplugin"]); + InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(App.CurrentWindow)); + var file = await picker.PickSaveFileAsync(); + if (file is null) return; + var result = await _pluginPackages.ExportPackageAsync(plugin, file.Path); + _statusText.Text = result.Message; + } + private async Task ClearPluginDataAsync(LoadedPlugin plugin) { if (plugin.State.Enabled) @@ -534,7 +607,7 @@ public sealed class PluginPage : Page var dialog = new ContentDialog { Title = "确认受控外接运行时", - Content = $"仅确认 {plugin.Manifest.Name} v{plugin.Manifest.Version}。插件版本或权限策略变化后需要重新确认。外接宿主不会获得 Tauri 全局 API、任意文件读取或原生程序执行能力。", + Content = $"仅确认 {plugin.Manifest.Name} v{plugin.Manifest.Version}。插件版本、权限策略、预构建资源或绑定工具哈希变化后需要重新确认。绑定工具只能通过已声明参数启动,不能提权、安装驱动或执行任意命令行。", PrimaryButtonText = "确认此版本", CloseButtonText = "取消", DefaultButton = ContentDialogButton.Close, @@ -549,6 +622,39 @@ public sealed class PluginPage : Page await NotifyCatalogChangedAsync(snapshot); } + private async Task ShowPluginNoticeAsync() + { + if (string.Equals(_settingsService.Current.PluginNoticeVersion, AgreementDocument.PluginNoticeVersion, StringComparison.Ordinal)) + { + return; + } + var remember = new CheckBox { Content = AppLocalizer.T("不再提示当前版本条款", "Do not show this version again") }; + var content = new StackPanel + { + Spacing = 10, + MaxWidth = 560, + Children = + { + ModernUi.Text(AppLocalizer.T("插件页面、插件内容和插件绑定的第三方工具均由相应作者或第三方提供,与 YMhut Box 主程序及官方内容没有关联。", "Plugin pages, content, and bound third-party tools are supplied by their authors and are not affiliated with the YMhut Box application or its official content."), 13.5, maxLines: 6), + ModernUi.Text(AppLocalizer.T("插件可能按清单申请网络、剪贴板、文件选择、外部链接或受控本地工具权限。请自行判断来源、准确性、版权和安全风险;YMhut Box 不保证第三方内容持续可用或没有错误。", "Plugins may request network, clipboard, file picker, external-link, or controlled local-tool permissions. Evaluate their source, accuracy, copyright, and security risks yourself."), 13, foreground: ModernUi.TextSecondary, maxLines: 8), + remember + } + }; + var dialog = new ContentDialog + { + Title = AppLocalizer.T("第三方插件使用提示", "Third-party plugin notice"), + Content = content, + PrimaryButtonText = AppLocalizer.T("我已了解", "I understand"), + DefaultButton = ContentDialogButton.Primary, + XamlRoot = XamlRoot + }; + await dialog.ShowAsync(); + if (remember.IsChecked == true) + { + await _settingsService.UpdateAsync(settings => settings.PluginNoticeVersion = AgreementDocument.PluginNoticeVersion); + } + } + private static Button RoundIconButton(string glyph, string tooltip, Func? click = null) { var button = new Button diff --git a/src/box-winUI/Views/ToolboxPage.cs b/src/box-winUI/Views/ToolboxPage.cs index 8c4b419..0048d37 100644 --- a/src/box-winUI/Views/ToolboxPage.cs +++ b/src/box-winUI/Views/ToolboxPage.cs @@ -1495,6 +1495,11 @@ public sealed class ToolboxPage : Page private static string SourceLabel(IToolModule module) { + if (!string.IsNullOrWhiteSpace(module.Metadata.Provider)) + { + return module.Metadata.Provider; + } + return module switch { ExternalToolModule => AppLocalizer.T("外部工具", "External tools"), diff --git a/src/box-winUI/Views/ToolboxSurface.cs b/src/box-winUI/Views/ToolboxSurface.cs index 2f0bbd1..44f7667 100644 --- a/src/box-winUI/Views/ToolboxSurface.cs +++ b/src/box-winUI/Views/ToolboxSurface.cs @@ -10,7 +10,8 @@ internal enum ToolboxSurfaceKind { Hardware, Optimization, - Music + Music, + OnlineTools } internal sealed record ToolboxSurfaceDefinition( @@ -68,7 +69,18 @@ internal static class ToolboxSurfaceCatalog "在线媒体", "Online media", ToolboxSurfaceKind.Music, - ["music", "netease", "player", "playlist", "音乐", "网易云", "播放器"]) + ["music", "netease", "player", "playlist", "音乐", "网易云", "播放器"]), + new( + "online_tools", + "联网工具", + "Online tools", + "按 AI、生活、网络、娱乐、学习、图像、数据、开发和实用分类访问安全免 Key 接口。", + "Browse allowlisted no-key APIs across nine task-focused categories.", + "\uE774", + "PearAPI · 74 项", + "PearAPI · 74 tools", + ToolboxSurfaceKind.OnlineTools, + ["online", "pearapi", "api", "联网", "在线", "接口"]) ]; internal static ToolboxSurfaceDefinition? Find(string idOrQuery) @@ -97,6 +109,7 @@ internal sealed class ToolboxSurfacePageFactory ToolboxSurfaceKind.Hardware => new HardwarePage(), ToolboxSurfaceKind.Optimization => new OptimizationPage(openTool), ToolboxSurfaceKind.Music => new NetworkMusicPage(), + ToolboxSurfaceKind.OnlineTools => new OnlineToolsPage(openTool), _ => throw new ArgumentOutOfRangeException(nameof(definition)) }; diff --git a/src/box-winUI/Views/Tools/AdaptiveToolPage.ResultRenderers.cs b/src/box-winUI/Views/Tools/AdaptiveToolPage.ResultRenderers.cs index 121b0c6..df9a639 100644 --- a/src/box-winUI/Views/Tools/AdaptiveToolPage.ResultRenderers.cs +++ b/src/box-winUI/Views/Tools/AdaptiveToolPage.ResultRenderers.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using YMhut.Box.WinUI.Controls; using System.Text.RegularExpressions; using Microsoft.UI.Text; using Microsoft.UI.Xaml; @@ -785,25 +786,7 @@ public abstract partial class AdaptiveToolPage private static Border BuildRankedItemCard(int rank, string title, string? meta, string? url) { - // Rank badge color: gold/silver/bronze for top 3, default for rest - var rankFg = rank switch - { - 1 => ModernUi.Brush("#CA5010"), // gold-ish / bronze - 2 => ModernUi.TextSecondary, - 3 => ModernUi.TextSecondary, - _ => ModernUi.TextSecondary - }; - var rankBg = rank switch - { - 1 => ModernUi.AccentSoft, - 2 => ModernUi.SurfaceAlt, - 3 => ModernUi.SurfaceAlt, - _ => ModernUi.SurfaceAlt - }; - - var rankBadge = ModernUi.Badge(rank.ToString(), rank == 1 ? ModernUi.Accent : rankFg, rankBg); - rankBadge.MinWidth = 32; - rankBadge.HorizontalAlignment = HorizontalAlignment.Center; + var rankBadge = RankBadgeFactory.Create(rank, 32); var text = new StackPanel { diff --git a/src/box-winUI/Views/Tools/AdaptiveToolPage.cs b/src/box-winUI/Views/Tools/AdaptiveToolPage.cs index d0067fe..943dd67 100644 --- a/src/box-winUI/Views/Tools/AdaptiveToolPage.cs +++ b/src/box-winUI/Views/Tools/AdaptiveToolPage.cs @@ -39,6 +39,7 @@ public abstract partial class AdaptiveToolPage : ToolPageBase private readonly ISettingsService _settingsService = AppServices.GetRequiredService(); private readonly IToolWorkerService _workerService = AppServices.GetRequiredService(); private readonly WebView2EnvironmentFactory _webViewEnvironmentFactory = AppServices.GetRequiredService(); + private readonly IRuntimeAssetService _runtimeAssets = AppServices.GetRequiredService(); private readonly ToolResultWebBridge _toolResultWebBridge = AppServices.GetRequiredService(); private readonly ToolPageWebBridge _toolPageWebBridge = AppServices.GetRequiredService(); private readonly IUiPerformanceCoordinator _uiPerformanceCoordinator = AppServices.GetRequiredService(); @@ -1803,21 +1804,6 @@ public abstract partial class AdaptiveToolPage : ToolPageBase try { - var assetsRoot = AppServices.GetRequiredService().Assets; - var resourceFolder = Path.Combine(assetsRoot, "tool-pages"); - if (!Directory.Exists(resourceFolder)) - { - resourceFolder = Path.Combine(AppContext.BaseDirectory, "Assets", "tool-pages"); - } - - if (!Directory.Exists(resourceFolder)) - { - _toolPageWebViewFailed = true; - loadingPlaceholder.Visibility = Visibility.Collapsed; - nativeFallback.Visibility = Visibility.Visible; - return; - } - var environment = await _webViewEnvironmentFactory.CreateAsync("ToolPages").ConfigureAwait(true); await _toolPageWebView.EnsureCoreWebView2Async(environment); _toolPageWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; @@ -1826,10 +1812,7 @@ public abstract partial class AdaptiveToolPage : ToolPageBase _toolPageWebView.CoreWebView2.Settings.IsStatusBarEnabled = false; _toolPageWebView.CoreWebView2.Settings.IsZoomControlEnabled = false; _toolPageWebView.CoreWebView2.WebMessageReceived += async (_, args) => await ToolPageWebView_WebMessageReceived(args); - _toolPageWebView.CoreWebView2.SetVirtualHostNameToFolderMapping( - "tool-pages.ymhut.local", - resourceFolder, - CoreWebView2HostResourceAccessKind.Allow); + _runtimeAssets.MapWebViewHost(_toolPageWebView.CoreWebView2, "tool-pages.ymhut.local", "tool-pages"); _toolPageWebView.NavigationCompleted += (_, args) => { if (!args.IsSuccess) @@ -2095,19 +2078,6 @@ public abstract partial class AdaptiveToolPage : ToolPageBase { try { - var assetsRoot = AppServices.GetRequiredService().Assets; - var resourceFolder = Path.Combine(assetsRoot, "tool-results"); - if (!Directory.Exists(resourceFolder)) - { - resourceFolder = Path.Combine(AppContext.BaseDirectory, "Assets", "tool-results"); - } - - if (!Directory.Exists(resourceFolder)) - { - _resultWebViewFailed = true; - return; - } - var environment = await _webViewEnvironmentFactory.CreateAsync("ToolResults").ConfigureAwait(true); await _resultWebView.EnsureCoreWebView2Async(environment); _resultWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; @@ -2116,10 +2086,7 @@ public abstract partial class AdaptiveToolPage : ToolPageBase _resultWebView.CoreWebView2.Settings.IsStatusBarEnabled = false; _resultWebView.CoreWebView2.Settings.IsZoomControlEnabled = false; _resultWebView.CoreWebView2.WebMessageReceived += async (_, args) => await _toolResultWebBridge.HandleMessageAsync(args); - _resultWebView.CoreWebView2.SetVirtualHostNameToFolderMapping( - "tool-results.ymhut.local", - resourceFolder, - CoreWebView2HostResourceAccessKind.Allow); + _runtimeAssets.MapWebViewHost(_resultWebView.CoreWebView2, "tool-results.ymhut.local", "tool-results"); _resultWebView.NavigationCompleted += (_, args) => { if (args.IsSuccess) diff --git a/src/box-winUI/Views/Tools/GeneratedPearApiToolPages.cs b/src/box-winUI/Views/Tools/GeneratedPearApiToolPages.cs new file mode 100644 index 0000000..170ed84 --- /dev/null +++ b/src/box-winUI/Views/Tools/GeneratedPearApiToolPages.cs @@ -0,0 +1,322 @@ +// +#nullable enable +using YMhut.Box.Core.Api; +using YMhut.Box.Core.Tools; +using YMhut.Box.WinUI.ViewModels.Tools; + +namespace YMhut.Box.WinUI.Views.Tools; + +public sealed partial class ToolPageRegistry +{ + private static partial void RegisterPearApi(ToolPageRegistry registry) + { + registry.Register("ai_latest_news", (module, goBack) => new AiLatestNewsPearApiToolPage(module, goBack)); + registry.Register("pear_ai_image_describe", (module, goBack) => new PearAiImageDescribePearApiToolPage(module, goBack)); + registry.Register("pear_embeddings", (module, goBack) => new PearEmbeddingsPearApiToolPage(module, goBack)); + registry.Register("pear_ai_image_detection", (module, goBack) => new PearAiImageDetectionPearApiToolPage(module, goBack)); + registry.Register("pear_ai_text_safety", (module, goBack) => new PearAiTextSafetyPearApiToolPage(module, goBack)); + registry.Register("pear_ai_dish_recognition", (module, goBack) => new PearAiDishRecognitionPearApiToolPage(module, goBack)); + registry.Register("pear_image_prompt", (module, goBack) => new PearImagePromptPearApiToolPage(module, goBack)); + registry.Register("ai_translation", (module, goBack) => new AiTranslationPearApiToolPage(module, goBack)); + registry.Register("pear_xf_spark", (module, goBack) => new PearXfSparkPearApiToolPage(module, goBack)); + registry.Register("pear_llm_reader", (module, goBack) => new PearLlmReaderPearApiToolPage(module, goBack)); + registry.Register("pear_hot_60_image", (module, goBack) => new PearHot60ImagePearApiToolPage(module, goBack)); + registry.Register("pear_daily_60_image", (module, goBack) => new PearDaily60ImagePearApiToolPage(module, goBack)); + registry.Register("hotboard", (module, goBack) => new HotboardPearApiToolPage(module, goBack)); + registry.Register("pear_world_60", (module, goBack) => new PearWorld60PearApiToolPage(module, goBack)); + registry.Register("oil_price", (module, goBack) => new OilPricePearApiToolPage(module, goBack)); + registry.Register("pear_life_calculator", (module, goBack) => new PearLifeCalculatorPearApiToolPage(module, goBack)); + registry.Register("pear_random_superpower", (module, goBack) => new PearRandomSuperpowerPearApiToolPage(module, goBack)); + registry.Register("pear_random_song", (module, goBack) => new PearRandomSongPearApiToolPage(module, goBack)); + registry.Register("pear_text_tts", (module, goBack) => new PearTextTtsPearApiToolPage(module, goBack)); + registry.Register("pear_ip_details", (module, goBack) => new PearIpDetailsPearApiToolPage(module, goBack)); + registry.Register("pear_gold_price", (module, goBack) => new PearGoldPricePearApiToolPage(module, goBack)); + registry.Register("movie_box_office", (module, goBack) => new MovieBoxOfficePearApiToolPage(module, goBack)); + registry.Register("pear_ruozhiba", (module, goBack) => new PearRuozhibaPearApiToolPage(module, goBack)); + registry.Register("pear_qq_level_image", (module, goBack) => new PearQqLevelImagePearApiToolPage(module, goBack)); + registry.Register("pear_historical_person", (module, goBack) => new PearHistoricalPersonPearApiToolPage(module, goBack)); + registry.Register("pear_countdown", (module, goBack) => new PearCountdownPearApiToolPage(module, goBack)); + registry.Register("pear_answers_book", (module, goBack) => new PearAnswersBookPearApiToolPage(module, goBack)); + registry.Register("city_route_query", (module, goBack) => new CityRouteQueryPearApiToolPage(module, goBack)); + registry.Register("pear_cookbook", (module, goBack) => new PearCookbookPearApiToolPage(module, goBack)); + registry.Register("pear_car_price", (module, goBack) => new PearCarPricePearApiToolPage(module, goBack)); + registry.Register("pear_lottery", (module, goBack) => new PearLotteryPearApiToolPage(module, goBack)); + registry.Register("pear_stock", (module, goBack) => new PearStockPearApiToolPage(module, goBack)); + registry.Register("pear_trademark", (module, goBack) => new PearTrademarkPearApiToolPage(module, goBack)); + registry.Register("pear_poem", (module, goBack) => new PearPoemPearApiToolPage(module, goBack)); + registry.Register("train_query", (module, goBack) => new TrainQueryPearApiToolPage(module, goBack)); + registry.Register("pear_certificate", (module, goBack) => new PearCertificatePearApiToolPage(module, goBack)); + registry.Register("pear_ssl", (module, goBack) => new PearSslPearApiToolPage(module, goBack)); + registry.Register("pear_icp", (module, goBack) => new PearIcpPearApiToolPage(module, goBack)); + registry.Register("pear_blood_type", (module, goBack) => new PearBloodTypePearApiToolPage(module, goBack)); + registry.Register("pear_brainteaser", (module, goBack) => new PearBrainteaserPearApiToolPage(module, goBack)); + registry.Register("pear_synonym_antonym", (module, goBack) => new PearSynonymAntonymPearApiToolPage(module, goBack)); + registry.Register("pear_surname_pun", (module, goBack) => new PearSurnamePunPearApiToolPage(module, goBack)); + registry.Register("pear_website_info", (module, goBack) => new PearWebsiteInfoPearApiToolPage(module, goBack)); + registry.Register("pear_baidu_place", (module, goBack) => new PearBaiduPlacePearApiToolPage(module, goBack)); + registry.Register("pear_audio_transcript", (module, goBack) => new PearAudioTranscriptPearApiToolPage(module, goBack)); + registry.Register("pear_question_search", (module, goBack) => new PearQuestionSearchPearApiToolPage(module, goBack)); + registry.Register("pear_funny_joke", (module, goBack) => new PearFunnyJokePearApiToolPage(module, goBack)); + registry.Register("pear_award", (module, goBack) => new PearAwardPearApiToolPage(module, goBack)); + registry.Register("pear_exchange_rate", (module, goBack) => new PearExchangeRatePearApiToolPage(module, goBack)); + registry.Register("pear_science_news", (module, goBack) => new PearScienceNewsPearApiToolPage(module, goBack)); + registry.Register("pear_kfc", (module, goBack) => new PearKfcPearApiToolPage(module, goBack)); + registry.Register("pear_sensitive_words", (module, goBack) => new PearSensitiveWordsPearApiToolPage(module, goBack)); + registry.Register("pear_ocr", (module, goBack) => new PearOcrPearApiToolPage(module, goBack)); + registry.Register("pear_chinese_conversion", (module, goBack) => new PearChineseConversionPearApiToolPage(module, goBack)); + registry.Register("history_today", (module, goBack) => new HistoryTodayPearApiToolPage(module, goBack)); + registry.Register("pear_rainbow", (module, goBack) => new PearRainbowPearApiToolPage(module, goBack)); + registry.Register("pear_couplet", (module, goBack) => new PearCoupletPearApiToolPage(module, goBack)); + registry.Register("pear_constellation", (module, goBack) => new PearConstellationPearApiToolPage(module, goBack)); + registry.Register("pear_mean_search", (module, goBack) => new PearMeanSearchPearApiToolPage(module, goBack)); + registry.Register("pear_country_population", (module, goBack) => new PearCountryPopulationPearApiToolPage(module, goBack)); + registry.Register("pear_world_population", (module, goBack) => new PearWorldPopulationPearApiToolPage(module, goBack)); + registry.Register("pear_keyword_guide", (module, goBack) => new PearKeywordGuidePearApiToolPage(module, goBack)); + registry.Register("pear_movie_lines", (module, goBack) => new PearMovieLinesPearApiToolPage(module, goBack)); + registry.Register("pear_today_food", (module, goBack) => new PearTodayFoodPearApiToolPage(module, goBack)); + registry.Register("pear_site_index", (module, goBack) => new PearSiteIndexPearApiToolPage(module, goBack)); + registry.Register("pear_short_recovery", (module, goBack) => new PearShortRecoveryPearApiToolPage(module, goBack)); + registry.Register("pear_college", (module, goBack) => new PearCollegePearApiToolPage(module, goBack)); + registry.Register("pear_love_reply", (module, goBack) => new PearLoveReplyPearApiToolPage(module, goBack)); + registry.Register("pear_quirky_dialogue", (module, goBack) => new PearQuirkyDialoguePearApiToolPage(module, goBack)); + registry.Register("pear_classic_joke", (module, goBack) => new PearClassicJokePearApiToolPage(module, goBack)); + registry.Register("pear_love_quote", (module, goBack) => new PearLoveQuotePearApiToolPage(module, goBack)); + registry.Register("pear_translation", (module, goBack) => new PearTranslationPearApiToolPage(module, goBack)); + registry.Register("pear_hitokoto", (module, goBack) => new PearHitokotoPearApiToolPage(module, goBack)); + registry.Register("pear_dujitang", (module, goBack) => new PearDujitangPearApiToolPage(module, goBack)); + registry.Register("baidu_hot", (module, goBack) => new BaiduHotPearApiToolPage(module, goBack)); + registry.Register("bili_hot", (module, goBack) => new BiliHotPearApiToolPage(module, goBack)); + registry.Register("zhihu_hot", (module, goBack) => new ZhihuHotPearApiToolPage(module, goBack)); + } +} + +public sealed class AiLatestNewsPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("ai_latest_news")); +public sealed class AiLatestNewsPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new AiLatestNewsPearApiToolViewModel(module), goBack); + +public sealed class PearAiImageDescribePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_ai_image_describe")); +public sealed class PearAiImageDescribePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearAiImageDescribePearApiToolViewModel(module), goBack); + +public sealed class PearEmbeddingsPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_embeddings")); +public sealed class PearEmbeddingsPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearEmbeddingsPearApiToolViewModel(module), goBack); + +public sealed class PearAiImageDetectionPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_ai_image_detection")); +public sealed class PearAiImageDetectionPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearAiImageDetectionPearApiToolViewModel(module), goBack); + +public sealed class PearAiTextSafetyPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_ai_text_safety")); +public sealed class PearAiTextSafetyPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearAiTextSafetyPearApiToolViewModel(module), goBack); + +public sealed class PearAiDishRecognitionPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_ai_dish_recognition")); +public sealed class PearAiDishRecognitionPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearAiDishRecognitionPearApiToolViewModel(module), goBack); + +public sealed class PearImagePromptPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_image_prompt")); +public sealed class PearImagePromptPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearImagePromptPearApiToolViewModel(module), goBack); + +public sealed class AiTranslationPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("ai_translation")); +public sealed class AiTranslationPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new AiTranslationPearApiToolViewModel(module), goBack); + +public sealed class PearXfSparkPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_xf_spark")); +public sealed class PearXfSparkPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearXfSparkPearApiToolViewModel(module), goBack); + +public sealed class PearLlmReaderPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_llm_reader")); +public sealed class PearLlmReaderPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearLlmReaderPearApiToolViewModel(module), goBack); + +public sealed class PearHot60ImagePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_hot_60_image")); +public sealed class PearHot60ImagePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearHot60ImagePearApiToolViewModel(module), goBack); + +public sealed class PearDaily60ImagePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_daily_60_image")); +public sealed class PearDaily60ImagePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearDaily60ImagePearApiToolViewModel(module), goBack); + +public sealed class HotboardPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("hotboard")); +public sealed class HotboardPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new HotboardPearApiToolViewModel(module), goBack); + +public sealed class PearWorld60PearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_world_60")); +public sealed class PearWorld60PearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearWorld60PearApiToolViewModel(module), goBack); + +public sealed class OilPricePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("oil_price")); +public sealed class OilPricePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new OilPricePearApiToolViewModel(module), goBack); + +public sealed class PearLifeCalculatorPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_life_calculator")); +public sealed class PearLifeCalculatorPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearLifeCalculatorPearApiToolViewModel(module), goBack); + +public sealed class PearRandomSuperpowerPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_random_superpower")); +public sealed class PearRandomSuperpowerPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearRandomSuperpowerPearApiToolViewModel(module), goBack); + +public sealed class PearRandomSongPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_random_song")); +public sealed class PearRandomSongPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearRandomSongPearApiToolViewModel(module), goBack); + +public sealed class PearTextTtsPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_text_tts")); +public sealed class PearTextTtsPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearTextTtsPearApiToolViewModel(module), goBack); + +public sealed class PearIpDetailsPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_ip_details")); +public sealed class PearIpDetailsPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearIpDetailsPearApiToolViewModel(module), goBack); + +public sealed class PearGoldPricePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_gold_price")); +public sealed class PearGoldPricePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearGoldPricePearApiToolViewModel(module), goBack); + +public sealed class MovieBoxOfficePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("movie_box_office")); +public sealed class MovieBoxOfficePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new MovieBoxOfficePearApiToolViewModel(module), goBack); + +public sealed class PearRuozhibaPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_ruozhiba")); +public sealed class PearRuozhibaPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearRuozhibaPearApiToolViewModel(module), goBack); + +public sealed class PearQqLevelImagePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_qq_level_image")); +public sealed class PearQqLevelImagePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearQqLevelImagePearApiToolViewModel(module), goBack); + +public sealed class PearHistoricalPersonPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_historical_person")); +public sealed class PearHistoricalPersonPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearHistoricalPersonPearApiToolViewModel(module), goBack); + +public sealed class PearCountdownPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_countdown")); +public sealed class PearCountdownPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearCountdownPearApiToolViewModel(module), goBack); + +public sealed class PearAnswersBookPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_answers_book")); +public sealed class PearAnswersBookPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearAnswersBookPearApiToolViewModel(module), goBack); + +public sealed class CityRouteQueryPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("city_route_query")); +public sealed class CityRouteQueryPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new CityRouteQueryPearApiToolViewModel(module), goBack); + +public sealed class PearCookbookPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_cookbook")); +public sealed class PearCookbookPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearCookbookPearApiToolViewModel(module), goBack); + +public sealed class PearCarPricePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_car_price")); +public sealed class PearCarPricePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearCarPricePearApiToolViewModel(module), goBack); + +public sealed class PearLotteryPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_lottery")); +public sealed class PearLotteryPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearLotteryPearApiToolViewModel(module), goBack); + +public sealed class PearStockPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_stock")); +public sealed class PearStockPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearStockPearApiToolViewModel(module), goBack); + +public sealed class PearTrademarkPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_trademark")); +public sealed class PearTrademarkPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearTrademarkPearApiToolViewModel(module), goBack); + +public sealed class PearPoemPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_poem")); +public sealed class PearPoemPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearPoemPearApiToolViewModel(module), goBack); + +public sealed class TrainQueryPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("train_query")); +public sealed class TrainQueryPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new TrainQueryPearApiToolViewModel(module), goBack); + +public sealed class PearCertificatePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_certificate")); +public sealed class PearCertificatePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearCertificatePearApiToolViewModel(module), goBack); + +public sealed class PearSslPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_ssl")); +public sealed class PearSslPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearSslPearApiToolViewModel(module), goBack); + +public sealed class PearIcpPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_icp")); +public sealed class PearIcpPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearIcpPearApiToolViewModel(module), goBack); + +public sealed class PearBloodTypePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_blood_type")); +public sealed class PearBloodTypePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearBloodTypePearApiToolViewModel(module), goBack); + +public sealed class PearBrainteaserPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_brainteaser")); +public sealed class PearBrainteaserPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearBrainteaserPearApiToolViewModel(module), goBack); + +public sealed class PearSynonymAntonymPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_synonym_antonym")); +public sealed class PearSynonymAntonymPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearSynonymAntonymPearApiToolViewModel(module), goBack); + +public sealed class PearSurnamePunPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_surname_pun")); +public sealed class PearSurnamePunPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearSurnamePunPearApiToolViewModel(module), goBack); + +public sealed class PearWebsiteInfoPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_website_info")); +public sealed class PearWebsiteInfoPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearWebsiteInfoPearApiToolViewModel(module), goBack); + +public sealed class PearBaiduPlacePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_baidu_place")); +public sealed class PearBaiduPlacePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearBaiduPlacePearApiToolViewModel(module), goBack); + +public sealed class PearAudioTranscriptPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_audio_transcript")); +public sealed class PearAudioTranscriptPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearAudioTranscriptPearApiToolViewModel(module), goBack); + +public sealed class PearQuestionSearchPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_question_search")); +public sealed class PearQuestionSearchPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearQuestionSearchPearApiToolViewModel(module), goBack); + +public sealed class PearFunnyJokePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_funny_joke")); +public sealed class PearFunnyJokePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearFunnyJokePearApiToolViewModel(module), goBack); + +public sealed class PearAwardPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_award")); +public sealed class PearAwardPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearAwardPearApiToolViewModel(module), goBack); + +public sealed class PearExchangeRatePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_exchange_rate")); +public sealed class PearExchangeRatePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearExchangeRatePearApiToolViewModel(module), goBack); + +public sealed class PearScienceNewsPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_science_news")); +public sealed class PearScienceNewsPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearScienceNewsPearApiToolViewModel(module), goBack); + +public sealed class PearKfcPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_kfc")); +public sealed class PearKfcPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearKfcPearApiToolViewModel(module), goBack); + +public sealed class PearSensitiveWordsPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_sensitive_words")); +public sealed class PearSensitiveWordsPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearSensitiveWordsPearApiToolViewModel(module), goBack); + +public sealed class PearOcrPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_ocr")); +public sealed class PearOcrPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearOcrPearApiToolViewModel(module), goBack); + +public sealed class PearChineseConversionPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_chinese_conversion")); +public sealed class PearChineseConversionPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearChineseConversionPearApiToolViewModel(module), goBack); + +public sealed class HistoryTodayPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("history_today")); +public sealed class HistoryTodayPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new HistoryTodayPearApiToolViewModel(module), goBack); + +public sealed class PearRainbowPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_rainbow")); +public sealed class PearRainbowPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearRainbowPearApiToolViewModel(module), goBack); + +public sealed class PearCoupletPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_couplet")); +public sealed class PearCoupletPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearCoupletPearApiToolViewModel(module), goBack); + +public sealed class PearConstellationPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_constellation")); +public sealed class PearConstellationPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearConstellationPearApiToolViewModel(module), goBack); + +public sealed class PearMeanSearchPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_mean_search")); +public sealed class PearMeanSearchPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearMeanSearchPearApiToolViewModel(module), goBack); + +public sealed class PearCountryPopulationPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_country_population")); +public sealed class PearCountryPopulationPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearCountryPopulationPearApiToolViewModel(module), goBack); + +public sealed class PearWorldPopulationPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_world_population")); +public sealed class PearWorldPopulationPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearWorldPopulationPearApiToolViewModel(module), goBack); + +public sealed class PearKeywordGuidePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_keyword_guide")); +public sealed class PearKeywordGuidePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearKeywordGuidePearApiToolViewModel(module), goBack); + +public sealed class PearMovieLinesPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_movie_lines")); +public sealed class PearMovieLinesPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearMovieLinesPearApiToolViewModel(module), goBack); + +public sealed class PearTodayFoodPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_today_food")); +public sealed class PearTodayFoodPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearTodayFoodPearApiToolViewModel(module), goBack); + +public sealed class PearSiteIndexPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_site_index")); +public sealed class PearSiteIndexPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearSiteIndexPearApiToolViewModel(module), goBack); + +public sealed class PearShortRecoveryPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_short_recovery")); +public sealed class PearShortRecoveryPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearShortRecoveryPearApiToolViewModel(module), goBack); + +public sealed class PearCollegePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_college")); +public sealed class PearCollegePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearCollegePearApiToolViewModel(module), goBack); + +public sealed class PearLoveReplyPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_love_reply")); +public sealed class PearLoveReplyPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearLoveReplyPearApiToolViewModel(module), goBack); + +public sealed class PearQuirkyDialoguePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_quirky_dialogue")); +public sealed class PearQuirkyDialoguePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearQuirkyDialoguePearApiToolViewModel(module), goBack); + +public sealed class PearClassicJokePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_classic_joke")); +public sealed class PearClassicJokePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearClassicJokePearApiToolViewModel(module), goBack); + +public sealed class PearLoveQuotePearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_love_quote")); +public sealed class PearLoveQuotePearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearLoveQuotePearApiToolViewModel(module), goBack); + +public sealed class PearTranslationPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_translation")); +public sealed class PearTranslationPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearTranslationPearApiToolViewModel(module), goBack); + +public sealed class PearHitokotoPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_hitokoto")); +public sealed class PearHitokotoPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearHitokotoPearApiToolViewModel(module), goBack); + +public sealed class PearDujitangPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("pear_dujitang")); +public sealed class PearDujitangPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new PearDujitangPearApiToolViewModel(module), goBack); + +public sealed class BaiduHotPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("baidu_hot")); +public sealed class BaiduHotPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new BaiduHotPearApiToolViewModel(module), goBack); + +public sealed class BiliHotPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("bili_hot")); +public sealed class BiliHotPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new BiliHotPearApiToolViewModel(module), goBack); + +public sealed class ZhihuHotPearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired("zhihu_hot")); +public sealed class ZhihuHotPearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new ZhihuHotPearApiToolViewModel(module), goBack); diff --git a/src/box-winUI/Views/Tools/GeneratedToolPages.cs b/src/box-winUI/Views/Tools/GeneratedToolPages.cs index a35d31c..157591d 100644 --- a/src/box-winUI/Views/Tools/GeneratedToolPages.cs +++ b/src/box-winUI/Views/Tools/GeneratedToolPages.cs @@ -70,9 +70,6 @@ public sealed partial class ToolPageRegistry registry.Register("dns_record_lookup", (module, goBack) => new DnsRecordLookupToolPage(module, goBack)); registry.Register("weather", (module, goBack) => new WeatherToolPage(module, goBack)); registry.Register("hotboard", (module, goBack) => new HotboardToolPage(module, goBack)); - registry.Register("bili_hot", (module, goBack) => new BiliHotToolPage(module, goBack)); - registry.Register("baidu_hot", (module, goBack) => new BaiduHotToolPage(module, goBack)); - registry.Register("zhihu_hot", (module, goBack) => new ZhihuHotToolPage(module, goBack)); registry.Register("cctv_news", (module, goBack) => new CctvNewsToolPage(module, goBack)); registry.Register("tech_news", (module, goBack) => new TechNewsToolPage(module, goBack)); registry.Register("ai_latest_news", (module, goBack) => new AiLatestNewsToolPage(module, goBack)); diff --git a/src/box-winUI/Views/Tools/PearApiToolPageBase.cs b/src/box-winUI/Views/Tools/PearApiToolPageBase.cs new file mode 100644 index 0000000..b81133d --- /dev/null +++ b/src/box-winUI/Views/Tools/PearApiToolPageBase.cs @@ -0,0 +1,768 @@ +using System.Text.Json; +using Microsoft.UI.Text; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.ApplicationModel.DataTransfer; +using Windows.Media.Core; +using Windows.Storage.Pickers; +using Windows.Storage.Streams; +using WinRT.Interop; +using YMhut.Box.Core.Api; +using YMhut.Box.Core.Settings; +using YMhut.Box.Core.Tools; +using YMhut.Box.WinUI.Services; +using YMhut.Box.WinUI.Controls; +using YMhut.Box.WinUI.ViewModels.Tools; + +namespace YMhut.Box.WinUI.Views.Tools; + +public abstract class PearApiToolPageBase : ToolPageBase +{ + private readonly Action? _goBack; + private readonly PearApiToolViewModel _viewModel; + private readonly PearApiToolDefinition _definition; + private readonly IPearApiClient _client = AppServices.GetRequiredService(); + private readonly ISettingsService _settings = AppServices.GetRequiredService(); + private readonly IToolUiDefinitionStore _definitionStore = AppServices.GetRequiredService(); + private readonly IShellDialogService _dialogService = AppServices.GetRequiredService(); + private readonly Dictionary _inputs = new(StringComparer.OrdinalIgnoreCase); + private readonly List _mediaStreams = []; + private readonly StackPanel _inputPanel = new() { Spacing = 12 }; + private readonly StackPanel _resultPanel = new() { Spacing = 8 }; + private readonly TextBlock _status = ModernUi.Text("等待查询", 12.5, FontWeights.SemiBold, ModernUi.TextSecondary, maxLines: 2); + private readonly ProgressRing _progress = new() { Width = 20, Height = 20, Visibility = Visibility.Collapsed }; + private readonly Button _runButton; + private readonly Button _cancelButton; + private readonly Grid _workGrid = new() { ColumnSpacing = 16, RowSpacing = 16 }; + private readonly Border _inputSurface; + private readonly Border _resultSurface; + private CancellationTokenSource? _requestCts; + private string _rawResult = string.Empty; + private bool _definitionValidated; + + protected PearApiToolPageBase( + IToolModule module, + PearApiToolViewModel viewModel, + Action? goBack = null) + { + _goBack = goBack; + _viewModel = viewModel; + _definition = viewModel.Definition; + BindModule(module); + Background = ModernUi.AppBackground; + + BuildInputs(); + _runButton = ModernUi.PillButton(ActionLabel(_definition.ResultKind), "\uE768", RunAsync, primary: true); + _cancelButton = ModernUi.PillButton("取消", "\uE711", CancelRequest); + _cancelButton.IsEnabled = false; + AutomationProperties.SetName(_runButton, $"运行{_definition.Name}"); + AutomationProperties.SetHelpText(_runButton, "仅访问固定的 PearAPI 免 Key 接口"); + + _inputSurface = BuildInputSurface(); + _resultSurface = BuildResultSurface(); + Content = BuildContent(); + Loaded += OnLoaded; + Unloaded += OnUnloaded; + SizeChanged += (_, e) => ApplyResponsiveLayout(e.NewSize.Width); + } + + public PearApiToolDefinition ApiDefinition => _definition; + + private IReadOnlyList VisibleParameters => + _definition.Parameters.Where(parameter => !PearApiPresentationSanitizer.IsHiddenField(parameter.Name)).ToArray(); + + private UIElement BuildContent() + { + var header = new Grid + { + MinHeight = 58, + Padding = new Thickness(16, 8, 18, 8), + ColumnSpacing = 12, + Background = ModernUi.Surface, + BorderBrush = ModernUi.Stroke, + BorderThickness = new Thickness(0, 0, 0, 1) + }; + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + header.ColumnDefinitions.Add(new ColumnDefinition()); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + var back = ModernUi.IconButton("\uE72B", "返回联网工具", () => _goBack?.Invoke()); + back.Width = 36; + back.Height = 36; + header.Children.Add(back); + var title = new StackPanel + { + Spacing = 1, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + ModernUi.Text(_definition.Name, 18, FontWeights.SemiBold, maxLines: 1), + ModernUi.Text($"PearAPI / {_definition.Category} / API {_definition.ApiId}", 11.5, foreground: ModernUi.TextSecondary, maxLines: 1) + } + }; + Grid.SetColumn(title, 1); + header.Children.Add(title); + var privacy = ModernUi.Badge(PrivacyLabel(_definition.PrivacyClass), ModernUi.Accent, ModernUi.AccentSoft); + privacy.VerticalAlignment = VerticalAlignment.Center; + Grid.SetColumn(privacy, 2); + header.Children.Add(privacy); + + _workGrid.ColumnDefinitions.Add(new ColumnDefinition()); + _workGrid.ColumnDefinitions.Add(new ColumnDefinition()); + _workGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + _workGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + _workGrid.Children.Add(_inputSurface); + Grid.SetColumn(_resultSurface, 1); + _workGrid.Children.Add(_resultSurface); + + var body = new ScrollViewer + { + Content = _workGrid, + Padding = new Thickness(18), + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + VerticalScrollMode = ScrollMode.Enabled, + ZoomMode = ZoomMode.Disabled + }; + var root = new Grid(); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition()); + root.Children.Add(header); + Grid.SetRow(body, 1); + root.Children.Add(body); + return root; + } + + private Border BuildInputSurface() + { + var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + actions.Children.Add(_runButton); + actions.Children.Add(_cancelButton); + actions.Children.Add(_progress); + var content = new StackPanel + { + Spacing = 14, + Children = + { + ModernUi.Text("查询与参数", 15, FontWeights.SemiBold, maxLines: 1), + ModernUi.Text(_definition.Description, 12.5, foreground: ModernUi.TextSecondary, maxLines: 4), + BuildPrivacyNotice(), + _inputPanel, + actions, + _status + } + }; + return ModernUi.Card(content, new Thickness(16), radius: 8, background: ModernUi.Surface); + } + + private Border BuildResultSurface() + { + var toolbar = new Grid { ColumnSpacing = 8 }; + toolbar.ColumnDefinitions.Add(new ColumnDefinition()); + toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + toolbar.Children.Add(ModernUi.Text(ResultTitle(_definition.ResultKind), 15, FontWeights.SemiBold, maxLines: 1)); + var copy = ModernUi.IconButton("\uE8C8", "复制结果", CopyResult); + Grid.SetColumn(copy, 1); + toolbar.Children.Add(copy); + _resultPanel.Children.Add(BuildEmptyState()); + var resultScroll = new ScrollViewer + { + Content = _resultPanel, + MinHeight = 320, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled + }; + return ModernUi.Card(new StackPanel { Spacing = 12, Children = { toolbar, resultScroll } }, new Thickness(16), radius: 8, background: ModernUi.Surface); + } + + private UIElement BuildPrivacyNotice() + { + var message = _definition.PrivacyClass switch + { + PearApiPrivacyClass.Public => "无输入公共请求,可在本次运行期间短时缓存。", + PearApiPrivacyClass.FileUpload => "文件仅在你确认并点击运行后发送至 PearAPI,不写入本地缓存。", + PearApiPrivacyClass.UserUrl => "网址会发送至 PearAPI;回环、私网和非 HTTP(S) 地址会被拒绝。", + PearApiPrivacyClass.Financial => "查询条件会发送至 PearAPI,结果仅供参考且不会持久化缓存。", + _ => "输入内容会发送至 PearAPI,不写入参数日志或本地缓存。" + }; + return new InfoBar + { + IsOpen = true, + IsClosable = false, + Severity = _definition.PrivacyClass is PearApiPrivacyClass.FileUpload or PearApiPrivacyClass.Financial + ? InfoBarSeverity.Warning + : InfoBarSeverity.Informational, + Title = "数据边界", + Message = message + }; + } + + private void BuildInputs() + { + if (!_definition.IsAvailableWithoutKey) + { + _inputPanel.Children.Add(new InfoBar + { + IsOpen = true, + IsClosable = false, + Severity = InfoBarSeverity.Warning, + Title = "当前不可用", + Message = _definition.AvailabilityNote + }); + return; + } + + foreach (var parameter in VisibleParameters) + { + var control = CreateInput(parameter); + _inputs[parameter.Name] = control; + _inputPanel.Children.Add(control); + } + + if (VisibleParameters.Count == 0) + { + _inputPanel.Children.Add(ModernUi.Text("此接口无需输入,点击刷新即可获取最新结果。", 13, foreground: ModernUi.TextSecondary)); + } + } + + private FrameworkElement CreateInput(PearApiParameterDefinition parameter) + { + var label = parameter.Required ? $"{parameter.DisplayName} *" : parameter.DisplayName; + if (parameter.Kind == PearApiParameterKind.Choice) + { + var combo = new ComboBox + { + Header = label, + PlaceholderText = parameter.Description, + HorizontalAlignment = HorizontalAlignment.Stretch, + ItemsSource = parameter.Options, + SelectedItem = parameter.Options?.FirstOrDefault(option => string.Equals(option, parameter.DefaultValue, StringComparison.OrdinalIgnoreCase)) + }; + if (combo.SelectedItem is null && parameter.Options is { Count: > 0 }) combo.SelectedIndex = 0; + return combo; + } + + if (parameter.Kind == PearApiParameterKind.Date) + { + return new CalendarDatePicker + { + Header = label, + PlaceholderText = parameter.Description, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + } + + if (parameter.Kind == PearApiParameterKind.Number) + { + _ = double.TryParse(parameter.DefaultValue, out var number); + return new NumberBox + { + Header = label, + PlaceholderText = parameter.Description, + Value = string.IsNullOrWhiteSpace(parameter.DefaultValue) ? double.NaN : number, + SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + } + + if (parameter.Kind == PearApiParameterKind.File) + { + var path = new TextBox + { + Header = label, + PlaceholderText = parameter.Description, + IsReadOnly = true, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + var choose = ModernUi.PillButton("选择文件", "\uE8E5", async () => await ChooseFileAsync(path)); + choose.HorizontalAlignment = HorizontalAlignment.Left; + return new StackPanel { Spacing = 7, Children = { path, choose } }; + } + + return new TextBox + { + Header = label, + PlaceholderText = parameter.Description, + Text = parameter.DefaultValue, + AcceptsReturn = parameter.Kind == PearApiParameterKind.MultilineText, + TextWrapping = TextWrapping.Wrap, + MinHeight = parameter.Kind == PearApiParameterKind.MultilineText ? 112 : 0, + MaxLength = parameter.MaxLength, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + } + + private async void OnLoaded(object sender, RoutedEventArgs e) + { + AttachDialogRoot(); + var uiDefinition = await _definitionStore.GetAsync(_viewModel.Module); + _definitionValidated = !uiDefinition.IsFallback; + RestoreState(); + UpdateRunState(); + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + CancelRequest(); + _ = SaveStateAsync(); + foreach (var stream in _mediaStreams) stream.Dispose(); + _mediaStreams.Clear(); + } + + private async void RunAsync() + { + if (!_definitionValidated || !_definition.IsAvailableWithoutKey || !await EnsureNoticeAcceptedAsync()) + { + return; + } + + var values = CollectValues(); + var filePath = _viewModel.SelectedFilePath; + var uploadConfirmed = false; + if (!string.IsNullOrWhiteSpace(filePath)) + { + uploadConfirmed = await ConfirmUploadAsync(filePath); + if (!uploadConfirmed) return; + } + + await SaveStateAsync(); + _requestCts?.Cancel(); + _requestCts?.Dispose(); + _requestCts = new CancellationTokenSource(); + SetBusy(true); + try + { + var response = await _client.SendAsync(new PearApiRequest(_definition.ToolId, values, filePath, uploadConfirmed), _requestCts.Token); + var sanitizedContent = PearApiPresentationSanitizer.SanitizeJson(response.Content); + _rawResult = sanitizedContent; + if (!response.Success) + { + ShowError(response.Error ?? "请求失败。"); + return; + } + + await RenderResultAsync(response with { Content = sanitizedContent }); + _status.Text = response.IsFromCache + ? "已显示本次运行期间的短时缓存结果。" + : $"请求完成 · HTTP {response.StatusCode} · {response.Elapsed.TotalMilliseconds:0} ms"; + } + finally + { + SetBusy(false); + } + } + + private void CancelRequest() => _requestCts?.Cancel(); + + private void SetBusy(bool value) + { + _progress.IsActive = value; + _progress.Visibility = value ? Visibility.Visible : Visibility.Collapsed; + _runButton.IsEnabled = !value && _definitionValidated && _definition.IsAvailableWithoutKey; + _cancelButton.IsEnabled = value; + _status.Text = value ? "正在请求 PearAPI..." : _status.Text; + } + + private void UpdateRunState() + { + _runButton.IsEnabled = _definitionValidated && _definition.IsAvailableWithoutKey; + if (!_definitionValidated) + { + _status.Text = "页面定义缺失或损坏,已禁用联网动作。"; + } + else if (!_definition.IsAvailableWithoutKey) + { + _status.Text = _definition.AvailabilityNote; + } + } + + private Dictionary CollectValues() + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var parameter in VisibleParameters) + { + if (!_inputs.TryGetValue(parameter.Name, out var input) || parameter.Kind == PearApiParameterKind.File) continue; + var value = input switch + { + TextBox text => text.Text, + ComboBox combo => combo.SelectedItem?.ToString() ?? string.Empty, + NumberBox number when !double.IsNaN(number.Value) => number.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), + CalendarDatePicker date when date.Date is { } selected => selected.ToString("yyyy-MM-dd"), + _ => string.Empty + }; + values[parameter.Name] = value.Trim(); + _viewModel.Values[parameter.Name] = value.Trim(); + } + return values; + } + + private async Task ChooseFileAsync(TextBox pathBox) + { + var picker = new FileOpenPicker { SuggestedStartLocation = PickerLocationId.PicturesLibrary }; + picker.FileTypeFilter.Add(".png"); + picker.FileTypeFilter.Add(".jpg"); + picker.FileTypeFilter.Add(".jpeg"); + picker.FileTypeFilter.Add(".webp"); + picker.FileTypeFilter.Add(".gif"); + if (_definition.ToolId == "pear_audio_transcript") + { + picker.FileTypeFilter.Add(".mp3"); + picker.FileTypeFilter.Add(".wav"); + picker.FileTypeFilter.Add(".ogg"); + picker.FileTypeFilter.Add(".mp4"); + picker.FileTypeFilter.Add(".webm"); + } + InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(App.CurrentWindow)); + var file = await picker.PickSingleFileAsync(); + if (file is null) return; + _viewModel.SelectedFilePath = file.Path; + pathBox.Text = file.Name; + } + + private async Task EnsureNoticeAcceptedAsync() + { + if (string.Equals(_settings.Current.PearApiNoticeVersion, AgreementDocument.PearApiNoticeVersion, StringComparison.Ordinal)) + { + return true; + } + + AttachDialogRoot(); + + var content = new StackPanel + { + Spacing = 10, + Children = + { + ModernUi.Text("联网工具使用固定的 PearAPI 免费接口,不携带 API Key,也不会自动切换到付费服务。", 13, maxLines: 4), + ModernUi.Text("运行工具时,页面明确列出的文本、网址、金融查询条件或你主动选择的文件会发送至 PearAPI。请求参数、上传内容和响应正文不写入应用日志。", 13, foreground: ModernUi.TextSecondary, maxLines: 6), + ModernUi.Text("第三方服务可能变更、限流或返回不准确内容;请勿提交个人隐私、机密文件或受版权限制的内容,结果仅供学习和参考。", 13, foreground: ModernUi.TextSecondary, maxLines: 5) + } + }; + var dialog = new ContentDialog + { + Title = "PearAPI 联网工具使用须知", + Content = content, + PrimaryButtonText = "同意并继续", + CloseButtonText = "取消", + DefaultButton = ContentDialogButton.Primary + }; + if (await _dialogService.ShowAsync(dialog) != ContentDialogResult.Primary) return false; + await _settings.UpdateAsync(settings => settings.PearApiNoticeVersion = AgreementDocument.PearApiNoticeVersion); + return true; + } + + private async Task ConfirmUploadAsync(string path) + { + AttachDialogRoot(); + var info = new FileInfo(path); + var dialog = new ContentDialog + { + Title = "确认发送文件", + Content = ModernUi.Text($"文件“{info.Name}”({FormatBytes(info.Length)})将发送至 PearAPI 处理。请确认文件不包含个人隐私、账号凭据或机密内容。", 13, maxLines: 6), + PrimaryButtonText = "发送并处理", + CloseButtonText = "取消", + DefaultButton = ContentDialogButton.Close + }; + return await _dialogService.ShowAsync(dialog) == ContentDialogResult.Primary; + } + + private void AttachDialogRoot() + { + // PearAPI pages can be created by navigation after the main window's + // global dialog service was initialized. Bind the live page root so + // ContentDialog never falls back to the not-yet-loaded toast host. + if (XamlRoot is not null) + { + _dialogService.Attach(XamlRoot); + } + } + + private async Task RenderResultAsync(PearApiResponse response) + { + _resultPanel.Children.Clear(); + if (response.BinaryContent is { Length: > 0 }) + { + await RenderBinaryAsync(response.BinaryContent, response.ContentType); + return; + } + + if (string.IsNullOrWhiteSpace(response.Content)) + { + _resultPanel.Children.Add(BuildEmptyState("接口返回空结果。")); + return; + } + + try + { + using var document = JsonDocument.Parse(response.Content); + var value = Unwrap(document.RootElement); + if (_definition.ResultKind == PearApiResultKind.Text) + { + _resultPanel.Children.Add(BuildTextResult(ExtractText(value))); + } + else if (value.ValueKind == JsonValueKind.Array) + { + RenderArray(value); + } + else if (value.ValueKind == JsonValueKind.Object) + { + RenderObject(value); + } + else + { + _resultPanel.Children.Add(BuildTextResult(value.ToString())); + } + } + catch (JsonException) + { + _resultPanel.Children.Add(BuildTextResult(response.Content)); + } + } + + private async Task RenderBinaryAsync(byte[] bytes, string contentType) + { + var stream = new InMemoryRandomAccessStream(); + using (var writer = new DataWriter(stream)) + { + writer.WriteBytes(bytes); + await writer.StoreAsync(); + writer.DetachStream(); + } + stream.Seek(0); + _mediaStreams.Add(stream); + if (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + var image = new BitmapImage(); + await image.SetSourceAsync(stream); + _resultPanel.Children.Add(new Image { Source = image, MaxHeight = 620, Stretch = Microsoft.UI.Xaml.Media.Stretch.Uniform }); + return; + } + if (contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) || contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)) + { + _resultPanel.Children.Add(new MediaPlayerElement + { + AreTransportControlsEnabled = true, + AutoPlay = false, + Source = MediaSource.CreateFromStream(stream, contentType), + MinHeight = 96 + }); + return; + } + _resultPanel.Children.Add(BuildTextResult($"收到 {FormatBytes(bytes.Length)} 二进制结果。")); + } + + private void RenderArray(JsonElement array) + { + var items = array.EnumerateArray().Take(200).ToArray(); + if (items.Length == 0) + { + _resultPanel.Children.Add(BuildEmptyState("未返回匹配项目。")); + return; + } + var index = 1; + foreach (var item in items) + { + var title = item.ValueKind == JsonValueKind.Object + ? FindString(item, "title", "name", "question", "content") + : item.ToString(); + var body = item.ValueKind == JsonValueKind.Object ? CompactObject(item, 8) : item.ToString(); + _resultPanel.Children.Add(BuildResultRow(index++, title, body)); + } + } + + private void RenderObject(JsonElement value) + { + var array = value.EnumerateObject().FirstOrDefault(property => property.Value.ValueKind == JsonValueKind.Array); + if (array.Value.ValueKind == JsonValueKind.Array) + { + RenderArray(array.Value); + return; + } + foreach (var property in value.EnumerateObject().Take(80)) + { + if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + { + _resultPanel.Children.Add(BuildKeyValue(property.Name, CompactObject(property.Value, 12))); + } + else + { + _resultPanel.Children.Add(BuildKeyValue(property.Name, property.Value.ToString())); + } + } + } + + private static UIElement BuildResultRow(int index, string title, string body) + { + var grid = new Grid { ColumnSpacing = 10 }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(32) }); + grid.ColumnDefinitions.Add(new ColumnDefinition()); + grid.Children.Add(RankBadgeFactory.Create(index, 30)); + var text = new StackPanel + { + Spacing = 3, + Children = + { + ModernUi.Text(string.IsNullOrWhiteSpace(title) ? $"项目 {index}" : title, 13.5, FontWeights.SemiBold, maxLines: 2), + ModernUi.Text(body, 12, foreground: ModernUi.TextSecondary, maxLines: 6) + } + }; + Grid.SetColumn(text, 1); + grid.Children.Add(text); + return ModernUi.Card(grid, new Thickness(10), radius: 6, background: ModernUi.SurfaceAlt); + } + + private static UIElement BuildKeyValue(string key, string value) + { + var grid = new Grid { ColumnSpacing = 12 }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(128) }); + grid.ColumnDefinitions.Add(new ColumnDefinition()); + grid.Children.Add(ModernUi.Text(key, 12, FontWeights.SemiBold, ModernUi.TextSecondary, maxLines: 2)); + var result = ModernUi.Text(value, 12.5, maxLines: 10); + result.TextWrapping = TextWrapping.Wrap; + Grid.SetColumn(result, 1); + grid.Children.Add(result); + return grid; + } + + private static UIElement BuildTextResult(string text) + { + var block = ModernUi.Text(string.IsNullOrWhiteSpace(text) ? "接口返回空文本。" : text, 13.5, maxLines: 200); + block.TextWrapping = TextWrapping.Wrap; + block.IsTextSelectionEnabled = true; + return block; + } + + private static UIElement BuildEmptyState(string text = "运行工具后,结构化结果会显示在这里。") + => new InfoBar { IsOpen = true, IsClosable = false, Severity = InfoBarSeverity.Informational, Message = text }; + + private void ShowError(string message) + { + _resultPanel.Children.Clear(); + _resultPanel.Children.Add(new InfoBar { IsOpen = true, IsClosable = false, Severity = InfoBarSeverity.Error, Title = "请求未完成", Message = message }); + _status.Text = message; + } + + private void CopyResult() + { + if (string.IsNullOrWhiteSpace(_rawResult)) return; + var package = new DataPackage(); + package.SetText(_rawResult); + Clipboard.SetContent(package); + ToastService.Show("结果已复制", ToastKind.Success, TimeSpan.FromSeconds(2)); + } + + private void RestoreState() + { + var state = _settings.GetToolUiState(_definition.ToolId); + foreach (var parameter in VisibleParameters) + { + if (!state.Values.TryGetValue(parameter.Name, out var value) || !_inputs.TryGetValue(parameter.Name, out var input)) continue; + switch (input) + { + case TextBox text: + text.Text = value; + break; + case ComboBox combo: + combo.SelectedItem = combo.Items.FirstOrDefault(item => string.Equals(item?.ToString(), value, StringComparison.OrdinalIgnoreCase)); + break; + case NumberBox number when double.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, out var parsed): + number.Value = parsed; + break; + case CalendarDatePicker date when DateTimeOffset.TryParse(value, out var parsedDate): + date.Date = parsedDate; + break; + } + } + } + + private Task SaveStateAsync() + => _settings.SaveToolUiStateAsync(_definition.ToolId, new ToolUiPageState { Values = CollectValues() }); + + private void ApplyResponsiveLayout(double width) + { + var wide = width >= 900; + _workGrid.ColumnDefinitions[0].Width = new GridLength(wide ? 0.92 : 1, GridUnitType.Star); + _workGrid.ColumnDefinitions[1].Width = wide ? new GridLength(1.28, GridUnitType.Star) : new GridLength(0); + Grid.SetColumn(_inputSurface, 0); + Grid.SetRow(_inputSurface, 0); + Grid.SetColumn(_resultSurface, wide ? 1 : 0); + Grid.SetRow(_resultSurface, wide ? 0 : 1); + _resultSurface.Margin = wide ? new Thickness(0) : new Thickness(0, 0, 0, 8); + } + + private static JsonElement Unwrap(JsonElement value) + { + if (value.ValueKind != JsonValueKind.Object) return value; + foreach (var name in new[] { "data", "result", "list", "items", "news" }) + { + if (value.TryGetProperty(name, out var nested) && nested.ValueKind is JsonValueKind.Object or JsonValueKind.Array) return nested; + } + return value; + } + + private static string ExtractText(JsonElement value) + { + if (value.ValueKind == JsonValueKind.String) return value.GetString() ?? string.Empty; + if (value.ValueKind == JsonValueKind.Object) + { + var candidate = FindString(value, "text", "content", "message", "msg", "answer", "translation", "result", "data"); + if (!string.IsNullOrWhiteSpace(candidate)) return candidate; + } + return value.ToString(); + } + + private static string FindString(JsonElement value, params string[] names) + { + if (value.ValueKind != JsonValueKind.Object) return string.Empty; + foreach (var name in names) + { + if (value.TryGetProperty(name, out var property) && property.ValueKind is JsonValueKind.String or JsonValueKind.Number) + return property.ToString(); + } + return string.Empty; + } + + private static string CompactObject(JsonElement value, int limit) + { + if (value.ValueKind == JsonValueKind.Object) + return string.Join(" · ", value.EnumerateObject().Take(limit).Select(property => $"{property.Name}: {property.Value}")); + if (value.ValueKind == JsonValueKind.Array) + return string.Join(" · ", value.EnumerateArray().Take(limit).Select(item => item.ToString())); + return value.ToString(); + } + + private static string ActionLabel(PearApiResultKind kind) => kind switch + { + PearApiResultKind.Audio => "生成音频", + PearApiResultKind.Image => "生成或查询", + PearApiResultKind.News or PearApiResultKind.RankedList => "刷新", + _ => "查询" + }; + + private static string ResultTitle(PearApiResultKind kind) => kind switch + { + PearApiResultKind.Audio => "音频结果", + PearApiResultKind.Image => "图像预览", + PearApiResultKind.News => "资讯列表", + PearApiResultKind.RankedList => "排行结果", + PearApiResultKind.Table => "数据表", + _ => "查询结果" + }; + + private static string PrivacyLabel(PearApiPrivacyClass privacy) => privacy switch + { + PearApiPrivacyClass.Public => "公共数据", + PearApiPrivacyClass.FileUpload => "文件上传", + PearApiPrivacyClass.UserUrl => "网址传输", + PearApiPrivacyClass.Financial => "金融参考", + _ => "文本传输" + }; + + private static string FormatBytes(long value) + { + if (value >= 1024 * 1024) return $"{value / 1024d / 1024d:0.##} MiB"; + if (value >= 1024) return $"{value / 1024d:0.##} KiB"; + return $"{value} B"; + } +} diff --git a/src/box-winUI/Views/Tools/ToolPageRegistry.cs b/src/box-winUI/Views/Tools/ToolPageRegistry.cs index bccd8d8..6029bf0 100644 --- a/src/box-winUI/Views/Tools/ToolPageRegistry.cs +++ b/src/box-winUI/Views/Tools/ToolPageRegistry.cs @@ -115,11 +115,14 @@ public sealed partial class ToolPageRegistry : IToolPageFactory { var registry = new ToolPageRegistry(); RegisterGenerated(registry); + RegisterPearApi(registry); return registry; } static partial void RegisterGenerated(ToolPageRegistry registry); + private static partial void RegisterPearApi(ToolPageRegistry registry); + private sealed record ToolPageDescriptor( string Id, Type PageType, diff --git a/src/box-winUI/YMhut.Box.WinUI.csproj b/src/box-winUI/YMhut.Box.WinUI.csproj index a56fb01..4d4d7f4 100644 --- a/src/box-winUI/YMhut.Box.WinUI.csproj +++ b/src/box-winUI/YMhut.Box.WinUI.csproj @@ -17,6 +17,7 @@ YMhutBox zh-CN $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\build\music-api')) + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\build\runtime-assets')) zh-CN;en-US @@ -26,6 +27,19 @@ + + + + + + + + + + @@ -49,7 +63,7 @@ win-x64 MSIX - true + true false Never none @@ -71,50 +85,31 @@ - + - - - - - - - - - YMhut.Box.WinUI.Assets.tool-results.vendor.%(Filename)%(Extension) + + + YMhut.Box.WinUI.Assets.app_icon.ico - + + + + Assets\runtime-assets.dat + PreserveNewest + + + Assets\data\ymhut-data.ybin + PreserveNewest + YMhut.Box.WinUI.version.json - - Assets\download.svg - PreserveNewest - - - - - Assets\icons\%(RecursiveDir)%(FileName)%(Extension) - PreserveNewest - - - Assets\images\%(RecursiveDir)%(FileName)%(Extension) - PreserveNewest - - - - Assets\data\%(RecursiveDir)%(FileName)%(Extension) - PreserveNewest - - - Assets\data\sanguosha\%(RecursiveDir)%(FileName)%(Extension) - PreserveNewest - - - Assets\data\rankings\%(Filename)%(Extension) + + Assets\data\tool-ui\%(FileName)%(Extension) PreserveNewest @@ -134,6 +129,28 @@ PreserveNewest + + + tauri-host\runtime.json + PreserveNewest + PreserveNewest + + + tauri-host\template.ymtemplate + PreserveNewest + PreserveNewest + + + tauri-host\LICENSES.txt + PreserveNewest + PreserveNewest + + + tauri-host\template\%(RecursiveDir)%(FileName)%(Extension) + PreserveNewest + PreserveNewest + +