From db6f5d90365a8a1e43564753ac2825adab970b3f Mon Sep 17 00:00:00 2001 From: chen08209 Date: Mon, 24 Aug 2026 17:54:23 +0800 Subject: [PATCH 01/14] refactor: rebuild core, providers, and application interface Changelog: Rebuilt the core, provider, and application layers Changelog-Type: feat --- .agents/architecture.md | 190 +- .agents/commands.md | 54 +- .agents/project.md | 90 + .agents/rules.md | 280 +- .github/scripts/generate_release_notes.sh | 28 - .../scripts/generate_release_notes_test.sh | 66 - .github/workflows/build.yaml | 194 +- .gitignore | 1 - .pre-commit-config.yaml | 6 + AGENTS.md | 2 + CHANGELOG.md | 523 +-- analysis_options.yaml | 37 +- android/app/build.gradle.kts | 7 + .../follow/clash/ServiceBroadcastReceiver.kt | 13 +- .../kotlin/com/follow/clash/ServiceState.kt | 318 +- .../com/follow/clash/ServiceStateHost.kt | 137 + .../com/follow/clash/ServiceStateMachine.kt | 343 ++ .../clash/packages/ChinaPackageMatcher.kt | 92 + .../follow/clash/packages/PackageResolver.kt | 67 +- .../com/follow/clash/plugins/AppPlugin.kt | 93 +- .../follow/clash/plugins/MainThreadResult.kt | 37 + .../com/follow/clash/plugins/ServicePlugin.kt | 6 +- android/build.gradle.kts | 3 + android/common/build.gradle.kts | 6 + .../com/follow/clash/common/BroadcastLease.kt | 37 + .../follow/clash/common/PendingCallback.kt | 57 + .../follow/clash/common/RunIntentArbiter.kt | 30 + android/core/src/main/cpp/core.cpp | 15 +- android/core/src/main/cpp/jni_helper.cpp | 62 +- android/core/src/main/cpp/jni_helper.h | 2 + android/gradle/libs.versions.toml | 4 + android/service/build.gradle.kts | 6 + .../com/follow/clash/service/VpnService.kt | 14 +- .../service/modules/NetworkObserveModule.kt | 14 +- android/tests/app/ChinaPackageMatcherTest.kt | 130 + android/tests/app/ServiceStateMachineTest.kt | 611 +++ android/tests/app/SharedStateTest.kt | 155 + android/tests/common/BroadcastLeaseTest.kt | 87 + android/tests/common/EnumsTest.kt | 47 + android/tests/common/PendingCallbackTest.kt | 164 + android/tests/common/RunIntentArbiterTest.kt | 137 + android/tests/service/ServiceConfigTest.kt | 76 + android/tests/service/TrafficTest.kt | 67 + android/tests/service/VpnOptionsTest.kt | 144 + arb/intl_en.arb | 35 +- arb/intl_ja.arb | 63 +- arb/intl_ru.arb | 63 +- arb/intl_zh_CN.arb | 35 +- changelog.json | 31 + core/Clash.Meta | 2 +- core/bride.c | 21 +- core/common.go | 331 +- core/common_test.go | 341 ++ core/config_test.go | 718 +++ core/constant.go | 16 +- core/dial_pipe.go | 6 +- core/dial_socket.go | 5 +- core/go.mod | 44 +- core/go.sum | 94 +- core/hub.go | 617 +-- core/hub_test.go | 686 +++ core/ipc_test.go | 637 +++ core/lib.go | 216 +- core/main.go | 4 +- core/main_cgo.go | 2 +- core/message.go | 128 +- core/message_test.go | 279 ++ core/method.go | 355 +- core/platform/limit.go | 48 +- core/platform/procfs.go | 71 +- core/server.go | 138 +- core/tun/tun.go | 4 +- lib/application.dart | 116 +- lib/bootstrap.dart | 214 + lib/common/app_localizations.dart | 30 + lib/common/app_ports.dart | 32 + lib/common/archive.dart | 22 - lib/common/cache.dart | 2 +- lib/common/changelog.dart | 57 + lib/common/color.dart | 16 +- lib/common/common.dart | 13 +- lib/common/compute.dart | 4 + lib/common/constant.dart | 38 +- lib/common/context.dart | 47 +- lib/common/converter.dart | 20 + lib/common/datetime.dart | 21 + lib/common/dav_client.dart | 7 +- lib/common/dialog.dart | 174 + lib/common/exception.dart | 8 + lib/common/fixed.dart | 70 +- lib/common/function.dart | 61 +- lib/common/http.dart | 23 +- lib/common/iterable.dart | 50 - lib/common/javascript.dart | 3 +- lib/common/keyboard.dart | 6 + lib/common/launch.dart | 18 +- lib/common/layout.dart | 24 + lib/common/link.dart | 11 +- lib/common/lock.dart | 13 +- lib/common/measure.dart | 27 +- lib/common/mixin.dart | 22 +- lib/common/navigator.dart | 6 +- lib/common/network.dart | 35 + lib/common/num.dart | 21 +- lib/common/package.dart | 48 + lib/common/path.dart | 59 +- lib/common/permission.dart | 37 +- lib/common/picker.dart | 18 +- lib/common/preferences.dart | 53 +- lib/common/print.dart | 11 + lib/common/provider_reader.dart | 3 + lib/common/request.dart | 120 +- lib/common/snowflake.dart | 25 + lib/common/store.dart | 33 - lib/common/string.dart | 41 +- lib/common/system.dart | 269 +- lib/common/system_dns.dart | 238 + lib/common/task.dart | 346 +- lib/common/task_pool.dart | 34 + lib/common/tray.dart | 292 +- lib/common/utils.dart | 358 -- lib/common/window.dart | 7 +- lib/core/controller.dart | 17 +- lib/core/desktop/helper_client.dart | 8 +- lib/core/desktop/transport.dart | 6 +- lib/core/event.dart | 2 +- lib/core/interface.dart | 105 +- lib/core/lib.dart | 45 +- lib/core/method.dart | 1 - lib/core/service.dart | 5 + lib/database/database.dart | 41 +- lib/database/groups.dart | 10 + lib/database/rules.dart | 31 +- lib/database/scripts.dart | 7 + lib/enum/enum.dart | 117 +- lib/features/overwrite/overwrite.dart | 5 + .../overwrite/overwrite_editor_page.dart | 198 + .../overwrite/overwrite_form_row.dart | 67 + .../overwrite/overwrite_nested_sheet.dart | 201 + .../overwrite/overwrite_selection_sheet.dart | 117 + .../overwrite/overwrite_stage_flow.dart | 160 + lib/features/overwrite/rule.dart | 283 +- lib/l10n/intl/messages_en.dart | 111 +- lib/l10n/intl/messages_ja.dart | 135 +- lib/l10n/intl/messages_ru.dart | 147 +- lib/l10n/intl/messages_zh_CN.dart | 105 +- lib/l10n/l10n.dart | 260 +- lib/main.dart | 65 +- lib/manager/android_manager.dart | 4 +- lib/manager/app_manager.dart | 144 +- lib/manager/connectivity_manager.dart | 68 +- lib/manager/core_manager.dart | 53 +- lib/manager/hotkey_manager.dart | 24 +- lib/manager/status_manager.dart | 566 ++- lib/manager/theme_manager.dart | 2 +- lib/manager/tile_manager.dart | 9 +- lib/manager/tray_manager.dart | 65 +- lib/manager/vpn_manager.dart | 5 +- lib/manager/window_manager.dart | 158 +- lib/models/app.dart | 2 +- lib/models/changelog.dart | 71 + lib/models/clash_config.dart | 66 +- lib/models/common.dart | 17 +- lib/models/config.dart | 34 +- lib/models/generated/changelog.freezed.dart | 1108 +++++ lib/models/generated/changelog.g.dart | 89 + .../generated/clash_config.freezed.dart | 1091 +++++ lib/models/generated/common.freezed.dart | 43 +- lib/models/generated/state.freezed.dart | 4016 +++++++++-------- lib/models/models.dart | 1 + lib/models/profile.dart | 38 +- lib/models/state.dart | 60 +- lib/pages/editor.dart | 554 ++- lib/pages/error.dart | 16 +- lib/pages/home.dart | 226 +- lib/pages/scan.dart | 41 +- lib/plugins/app.dart | 14 +- lib/plugins/service.dart | 6 +- lib/plugins/tile.dart | 33 +- lib/providers/action.dart | 3 +- lib/providers/actions/backup.dart | 68 +- lib/providers/actions/common.dart | 113 +- lib/providers/actions/core.dart | 30 +- lib/providers/actions/geo_resource.dart | 6 +- lib/providers/actions/profiles.dart | 37 +- lib/providers/actions/proxies.dart | 209 +- lib/providers/actions/setup.dart | 69 +- lib/providers/actions/store.dart | 30 +- lib/providers/actions/system.dart | 35 +- lib/providers/app.dart | 82 +- lib/providers/config.dart | 2 - lib/providers/core.dart | 7 + lib/providers/database.dart | 502 +-- lib/providers/generated/action.g.dart | 18 +- lib/providers/generated/app.g.dart | 62 +- lib/providers/generated/config.g.dart | 2 +- lib/providers/generated/core.g.dart | 51 + lib/providers/generated/database.g.dart | 14 +- lib/providers/generated/state.g.dart | 3419 +++++++------- lib/providers/providers.dart | 1 + lib/providers/state.dart | 817 +--- lib/providers/state/navigation.dart | 118 + lib/providers/state/overwrite.dart | 114 + lib/providers/state/profile.dart | 83 + lib/providers/state/proxies.dart | 268 ++ lib/providers/state/system.dart | 216 + lib/providers/state/theme.dart | 75 + lib/state.dart | 349 +- lib/views/about.dart | 25 +- lib/views/access.dart | 127 +- lib/views/application_setting.dart | 345 +- lib/views/backup_and_restore.dart | 175 +- lib/views/config/advanced.dart | 3 +- lib/views/config/dns.dart | 701 +-- lib/views/config/general.dart | 884 +--- lib/views/config/general/port_dialog.dart | 260 ++ lib/views/config/general/ua_dialog.dart | 180 + lib/views/config/network.dart | 321 +- lib/views/config/on_demand.dart | 338 +- lib/views/config/rules.dart | 8 +- lib/views/config/scripts.dart | 59 +- lib/views/connection/connections.dart | 12 +- lib/views/connection/item.dart | 13 +- lib/views/connection/requests.dart | 16 +- lib/views/dashboard/dashboard.dart | 40 +- lib/views/dashboard/widget_registry.dart | 52 + .../dashboard/widgets/core_status_button.dart | 8 +- lib/views/dashboard/widgets/memory_info.dart | 41 +- .../dashboard/widgets/network_detection.dart | 3 +- .../dashboard/widgets/outbound_mode.dart | 157 +- .../dashboard/widgets/quick_options.dart | 225 +- lib/views/dashboard/widgets/start_button.dart | 2 +- .../dashboard/widgets/traffic_usage.dart | 358 +- lib/views/developer.dart | 29 +- lib/views/hotkey.dart | 22 +- lib/views/logs.dart | 37 +- lib/{common => views}/navigation.dart | 4 +- lib/views/profiles/add.dart | 41 +- lib/views/profiles/edit.dart | 323 +- .../profiles/overwrite/custom/custom.dart | 139 +- .../profiles/overwrite/custom/groups.dart | 528 +-- lib/views/profiles/overwrite/custom/icon.dart | 320 +- .../overwrite/custom/name_add_picker.dart | 173 + .../overwrite/custom/name_list_editor.dart | 267 ++ .../profiles/overwrite/custom/proxies.dart | 601 +-- .../overwrite/custom/proxy_providers.dart | 472 +- .../profiles/overwrite/custom/rules.dart | 918 +--- .../profiles/overwrite/custom/widgets.dart | 47 +- lib/views/profiles/overwrite/overwrite.dart | 10 +- lib/views/profiles/overwrite/script.dart | 179 +- lib/views/profiles/overwrite/standard.dart | 74 +- lib/views/profiles/preview.dart | 10 +- lib/views/profiles/profiles.dart | 369 +- lib/views/proxies/card.dart | 32 +- lib/views/proxies/common.dart | 73 +- lib/views/proxies/list.dart | 377 +- lib/views/proxies/providers.dart | 75 +- lib/views/proxies/proxies.dart | 9 +- lib/views/proxies/tab.dart | 41 +- lib/views/resources.dart | 24 +- lib/views/theme.dart | 279 +- lib/views/tools.dart | 11 +- lib/widgets/animate_grid.dart | 85 - lib/widgets/animated_cross_slide.dart | 279 -- lib/widgets/bar_chart.dart | 151 - lib/widgets/config_item.dart | 211 + lib/widgets/container.dart | 77 - lib/widgets/dialog.dart | 6 +- lib/widgets/dismissible.dart | 7 +- lib/widgets/grid.dart | 180 +- lib/widgets/icon.dart | 20 +- lib/widgets/inherited.dart | 12 + lib/widgets/input.dart | 496 +- lib/widgets/input_pages.dart | 499 ++ lib/widgets/list.dart | 208 +- lib/widgets/list_selected.dart | 197 + lib/widgets/palette.dart | 95 +- lib/widgets/popup.dart | 866 ++-- lib/widgets/scaffold.dart | 10 +- lib/widgets/scroll.dart | 5 - lib/widgets/sheet.dart | 247 +- lib/widgets/super_grid.dart | 653 +-- lib/widgets/tab.dart | 727 +-- lib/widgets/tab_render.dart | 539 +++ lib/widgets/tab_segment.dart | 182 + lib/widgets/text.dart | 2 +- lib/widgets/view.dart | 17 - lib/widgets/widgets.dart | 3 +- lint_options.yaml | 53 + linux/flutter/generated_plugin_registrant.cc | 8 +- linux/flutter/generated_plugins.cmake | 2 +- macos/Flutter/GeneratedPluginRegistrant.swift | 6 +- plugins/proxy/analysis_options.yaml | 5 +- plugins/rust_api/analysis_options.yaml | 8 +- plugins/setup/analysis_options.yaml | 2 +- plugins/tray/.gitignore | 4 + plugins/tray/LICENSE | 21 + plugins/tray/README.md | 61 + plugins/tray/analysis_options.yaml | 1 + plugins/tray/lib/src/tray.dart | 196 + plugins/tray/lib/src/tray_capabilities.dart | 58 + plugins/tray/lib/src/tray_codec.dart | 92 + plugins/tray/lib/src/tray_event.dart | 19 + plugins/tray/lib/src/tray_menu.dart | 47 + plugins/tray/lib/src/tray_spec.dart | 25 + plugins/tray/lib/tray.dart | 5 + plugins/tray/linux/CMakeLists.txt | 40 + plugins/tray/linux/include/tray/tray_plugin.h | 26 + plugins/tray/linux/tray_plugin.cc | 278 ++ plugins/tray/macos/tray/Package.swift | 25 + .../macos/tray/Sources/tray/TrayMenu.swift | 49 + .../macos/tray/Sources/tray/TrayPlugin.swift | 114 + .../tray/Sources/tray/TrayStatusItem.swift | 156 + .../tray/Sources/tray/TrayTitleView.swift | 77 + plugins/tray/pubspec.yaml | 27 + .../test/macos_title_view_source_test.dart | 79 + plugins/tray/test/tray_codec_test.dart | 102 + plugins/tray/test/tray_test.dart | 146 + .../tray/test/windows_menu_source_test.dart | 52 + plugins/tray/windows/CMakeLists.txt | 32 + .../windows/include/tray/tray_plugin_c_api.h | 23 + plugins/tray/windows/tray_plugin.cpp | 309 ++ plugins/tray/windows/tray_plugin.h | 63 + plugins/tray/windows/tray_plugin_c_api.cpp | 12 + plugins/wifi_ssid/analysis_options.yaml | 6 +- plugins/window_ext/analysis_options.yaml | 5 +- .../window_ext/lib/window_ext_manager.dart | 6 +- .../test/window_ext_manager_test.dart | 54 + pubspec.lock | 122 +- pubspec.yaml | 25 +- release_telegram.py | 57 +- services/helper/src/service/hub.rs | 54 +- test/application_test.dart | 141 + test/common/app_localizations_test.dart | 42 + test/common/backup_task_test.dart | 585 +++ test/common/changelog_test.dart | 103 + test/common/dialog_test.dart | 243 + test/common/exception_test.dart | 34 + test/common/fixed_test.dart | 93 +- test/common/function_test.dart | 152 + test/common/future_test.dart | 110 + test/common/helpers_test.dart | 282 ++ test/common/http_test.dart | 78 + test/common/iterable_test.dart | 74 - test/common/launch_test.dart | 91 + test/common/link_test.dart | 81 + test/common/lock_test.dart | 71 + test/common/measure_test.dart | 81 + test/common/navigator_test.dart | 257 ++ test/common/network_test.dart | 131 + test/common/picker_test.dart | 33 +- test/common/preferences_test.dart | 128 + test/common/print_test.dart | 29 + test/common/request_test.dart | 35 + test/common/scroll_behavior_test.dart | 230 + test/common/string_test.dart | 13 + test/common/system_dns_test.dart | 336 ++ test/common/system_test.dart | 342 ++ test/common/task_pool_test.dart | 63 + test/common/task_test.dart | 67 +- test/common/tray_lifecycle_test.dart | 79 + test/common/tray_menu_test.dart | 235 + test/common/tray_test.dart | 10 +- test/common/utils_test.dart | 217 - test/common/window_test.dart | 61 + test/core/controller_test.dart | 12 +- test/core/desktop/fakes.dart | 6 +- test/core/desktop/transport_test.dart | 42 + test/core/event_test.dart | 53 + test/core/interface_message_test.dart | 126 + test/core/lib_test.dart | 291 ++ test/database/dao_coverage_test.dart | 97 + test/database/migration_v1_to_v2_test.dart | 159 + test/enum/enum_test.dart | 12 +- .../features/overwrite_custom_views_test.dart | 185 + test/features/overwrite_editor_page_test.dart | 205 + .../features/overwrite_nested_sheet_test.dart | 161 + test/features/overwrite_rule_dialog_test.dart | 105 + test/features/overwrite_rule_form_test.dart | 170 + test/features/overwrite_stage_flow_test.dart | 181 + test/features/overwrite_view_test.dart | 106 + test/helpers/test_app.dart | 57 + test/helpers/test_database_providers.dart | 20 + test/helpers/test_profiles.dart | 37 + test/l10n/app_localizations_test.dart | 585 +-- test/lint/dead_file_test.dart | 94 + test/lint/disposable_field_test.dart | 111 + test/lint/dynamic_message_key_test.dart | 175 + test/lint/icon_button_tooltip_test.dart | 79 + test/lint/platform_layering_test.dart | 163 + test/lint/ui_layer_singleton_test.dart | 55 + test/manager/android_manager_test.dart | 93 + test/manager/connectivity_manager_test.dart | 194 + test/manager/core_manager_test.dart | 140 +- test/manager/hotkey_manager_test.dart | 29 + test/manager/proxy_manager_test.dart | 110 + test/manager/status_manager_test.dart | 483 ++ test/manager/tile_manager_test.dart | 123 + test/manager/tray_manager_test.dart | 169 + test/manager/vpn_manager_test.dart | 121 + test/manager/window_manager_test.dart | 285 ++ test/models/changelog_test.dart | 117 + test/pages/editor_test.dart | 56 + test/pages/error_test.dart | 71 + test/pages/home_test.dart | 55 +- test/pages/scan_test.dart | 265 ++ test/plugins/service_test.dart | 357 ++ test/plugins/tile_test.dart | 133 + test/providers/action_test.dart | 45 +- test/providers/app_test.dart | 68 + test/providers/backup_action_test.dart | 223 + test/providers/common_action_test.dart | 172 + test/providers/database_test.dart | 728 +++ test/providers/permission_test.dart | 6 +- test/providers/proxies_action_test.dart | 589 +++ test/providers/setup_action_test.dart | 395 ++ test/providers/state_derived_test.dart | 41 +- test/providers/store_action_test.dart | 191 + test/providers/system_action_test.dart | 165 + test/state_run_globals_test.dart | 101 + test/state_run_test.dart | 218 + test/state_test.dart | 68 +- test/tool/changelog_e2e_test.dart | 180 + test/tool/changelog_parser_test.dart | 211 + test/tool/changelog_render_test.dart | 307 ++ test/views/access_test.dart | 266 ++ test/views/add_profile_view_test.dart | 136 + test/views/added_rules_test.dart | 140 + test/views/backup_and_restore_test.dart | 233 + test/views/connections_test.dart | 138 + test/views/custom_overwrite_edit_test.dart | 228 + test/views/dashboard/quick_options_test.dart | 140 + test/views/developer_view_test.dart | 103 + test/views/hotkey_view_test.dart | 280 ++ test/views/icon_edit_view_test.dart | 119 + test/views/name_add_picker_test.dart | 252 ++ test/views/network_config_test.dart | 175 + test/views/profile_preview_test.dart | 146 + test/views/proxy_providers_view_test.dart | 219 + test/views/requests_test.dart | 140 + test/views/script_content_test.dart | 148 + test/views/theme_test.dart | 125 + test/widgets/connection_item_test.dart | 118 + test/widgets/core_status_button_test.dart | 57 +- test/widgets/dashboard_layout_test.dart | 34 +- test/widgets/grid_test.dart | 106 + test/widgets/input_test.dart | 100 +- test/widgets/list_header_focus_test.dart | 9 +- test/widgets/memory_info_test.dart | 157 +- test/widgets/on_demand_test.dart | 152 + test/widgets/palette_test.dart | 42 +- test/widgets/popup_test.dart | 540 +++ test/widgets/port_dialog_test.dart | 136 + test/widgets/profile_focus_test.dart | 11 +- test/widgets/proxies_tab_test.dart | 81 +- test/widgets/scaffold_back_test.dart | 1 + test/widgets/sheet_height_test.dart | 85 + test/widgets/start_button_test.dart | 50 +- test/widgets/super_grid_test.dart | 62 +- .../widgets/text_scale_notification_test.dart | 87 + test/widgets/uncovered_views_test.dart | 290 ++ test/widgets/update_dialog_test.dart | 159 + test/widgets/views_smoke_test.dart | 130 +- test/widgets/wave_test.dart | 133 + tool/changelog.dart | 282 ++ tool/check_commit_msg.sh | 128 + tool/check_commit_msg_test.sh | 127 + tool/check_coverage.dart | 182 + tool/check_plugins.sh | 46 + tool/src/changelog/builder.dart | 195 + tool/src/changelog/git.dart | 163 + tool/src/changelog/models.dart | 163 + tool/src/changelog/parser.dart | 190 + tool/src/changelog/render.dart | 141 + .../flutter/generated_plugin_registrant.cc | 6 +- windows/flutter/generated_plugins.cmake | 2 +- 476 files changed, 50117 insertions(+), 19840 deletions(-) delete mode 100644 .github/scripts/generate_release_notes.sh delete mode 100644 .github/scripts/generate_release_notes_test.sh create mode 100644 android/app/src/main/kotlin/com/follow/clash/ServiceStateHost.kt create mode 100644 android/app/src/main/kotlin/com/follow/clash/ServiceStateMachine.kt create mode 100644 android/app/src/main/kotlin/com/follow/clash/packages/ChinaPackageMatcher.kt create mode 100644 android/app/src/main/kotlin/com/follow/clash/plugins/MainThreadResult.kt create mode 100644 android/common/src/main/java/com/follow/clash/common/BroadcastLease.kt create mode 100644 android/common/src/main/java/com/follow/clash/common/PendingCallback.kt create mode 100644 android/common/src/main/java/com/follow/clash/common/RunIntentArbiter.kt create mode 100644 android/tests/app/ChinaPackageMatcherTest.kt create mode 100644 android/tests/app/ServiceStateMachineTest.kt create mode 100644 android/tests/app/SharedStateTest.kt create mode 100644 android/tests/common/BroadcastLeaseTest.kt create mode 100644 android/tests/common/EnumsTest.kt create mode 100644 android/tests/common/PendingCallbackTest.kt create mode 100644 android/tests/common/RunIntentArbiterTest.kt create mode 100644 android/tests/service/ServiceConfigTest.kt create mode 100644 android/tests/service/TrafficTest.kt create mode 100644 android/tests/service/VpnOptionsTest.kt create mode 100644 changelog.json create mode 100644 core/common_test.go create mode 100644 core/config_test.go create mode 100644 core/hub_test.go create mode 100644 core/ipc_test.go create mode 100644 core/message_test.go create mode 100644 lib/bootstrap.dart create mode 100644 lib/common/app_ports.dart delete mode 100644 lib/common/archive.dart create mode 100644 lib/common/changelog.dart create mode 100644 lib/common/dialog.dart create mode 100644 lib/common/exception.dart create mode 100644 lib/common/layout.dart create mode 100644 lib/common/provider_reader.dart delete mode 100644 lib/common/store.dart create mode 100644 lib/common/system_dns.dart create mode 100644 lib/common/task_pool.dart delete mode 100644 lib/common/utils.dart create mode 100644 lib/features/overwrite/overwrite_editor_page.dart create mode 100644 lib/features/overwrite/overwrite_form_row.dart create mode 100644 lib/features/overwrite/overwrite_nested_sheet.dart create mode 100644 lib/features/overwrite/overwrite_selection_sheet.dart create mode 100644 lib/features/overwrite/overwrite_stage_flow.dart create mode 100644 lib/models/changelog.dart create mode 100644 lib/models/generated/changelog.freezed.dart create mode 100644 lib/models/generated/changelog.g.dart create mode 100644 lib/providers/core.dart create mode 100644 lib/providers/generated/core.g.dart create mode 100644 lib/providers/state/navigation.dart create mode 100644 lib/providers/state/overwrite.dart create mode 100644 lib/providers/state/profile.dart create mode 100644 lib/providers/state/proxies.dart create mode 100644 lib/providers/state/system.dart create mode 100644 lib/providers/state/theme.dart create mode 100644 lib/views/config/general/port_dialog.dart create mode 100644 lib/views/config/general/ua_dialog.dart create mode 100644 lib/views/dashboard/widget_registry.dart rename lib/{common => views}/navigation.dart (96%) create mode 100644 lib/views/profiles/overwrite/custom/name_add_picker.dart create mode 100644 lib/views/profiles/overwrite/custom/name_list_editor.dart delete mode 100644 lib/widgets/animate_grid.dart delete mode 100644 lib/widgets/animated_cross_slide.dart delete mode 100644 lib/widgets/bar_chart.dart create mode 100644 lib/widgets/config_item.dart delete mode 100644 lib/widgets/container.dart create mode 100644 lib/widgets/input_pages.dart create mode 100644 lib/widgets/list_selected.dart create mode 100644 lib/widgets/tab_render.dart create mode 100644 lib/widgets/tab_segment.dart delete mode 100644 lib/widgets/view.dart create mode 100644 lint_options.yaml create mode 100644 plugins/tray/.gitignore create mode 100644 plugins/tray/LICENSE create mode 100644 plugins/tray/README.md create mode 100644 plugins/tray/analysis_options.yaml create mode 100644 plugins/tray/lib/src/tray.dart create mode 100644 plugins/tray/lib/src/tray_capabilities.dart create mode 100644 plugins/tray/lib/src/tray_codec.dart create mode 100644 plugins/tray/lib/src/tray_event.dart create mode 100644 plugins/tray/lib/src/tray_menu.dart create mode 100644 plugins/tray/lib/src/tray_spec.dart create mode 100644 plugins/tray/lib/tray.dart create mode 100644 plugins/tray/linux/CMakeLists.txt create mode 100644 plugins/tray/linux/include/tray/tray_plugin.h create mode 100644 plugins/tray/linux/tray_plugin.cc create mode 100644 plugins/tray/macos/tray/Package.swift create mode 100644 plugins/tray/macos/tray/Sources/tray/TrayMenu.swift create mode 100644 plugins/tray/macos/tray/Sources/tray/TrayPlugin.swift create mode 100644 plugins/tray/macos/tray/Sources/tray/TrayStatusItem.swift create mode 100644 plugins/tray/macos/tray/Sources/tray/TrayTitleView.swift create mode 100644 plugins/tray/pubspec.yaml create mode 100644 plugins/tray/test/macos_title_view_source_test.dart create mode 100644 plugins/tray/test/tray_codec_test.dart create mode 100644 plugins/tray/test/tray_test.dart create mode 100644 plugins/tray/test/windows_menu_source_test.dart create mode 100644 plugins/tray/windows/CMakeLists.txt create mode 100644 plugins/tray/windows/include/tray/tray_plugin_c_api.h create mode 100644 plugins/tray/windows/tray_plugin.cpp create mode 100644 plugins/tray/windows/tray_plugin.h create mode 100644 plugins/tray/windows/tray_plugin_c_api.cpp create mode 100644 plugins/window_ext/test/window_ext_manager_test.dart create mode 100644 test/application_test.dart create mode 100644 test/common/app_localizations_test.dart create mode 100644 test/common/backup_task_test.dart create mode 100644 test/common/changelog_test.dart create mode 100644 test/common/dialog_test.dart create mode 100644 test/common/exception_test.dart create mode 100644 test/common/future_test.dart create mode 100644 test/common/helpers_test.dart create mode 100644 test/common/http_test.dart create mode 100644 test/common/launch_test.dart create mode 100644 test/common/link_test.dart create mode 100644 test/common/lock_test.dart create mode 100644 test/common/measure_test.dart create mode 100644 test/common/navigator_test.dart create mode 100644 test/common/network_test.dart create mode 100644 test/common/preferences_test.dart create mode 100644 test/common/print_test.dart create mode 100644 test/common/request_test.dart create mode 100644 test/common/scroll_behavior_test.dart create mode 100644 test/common/system_dns_test.dart create mode 100644 test/common/system_test.dart create mode 100644 test/common/task_pool_test.dart create mode 100644 test/common/tray_lifecycle_test.dart create mode 100644 test/common/tray_menu_test.dart delete mode 100644 test/common/utils_test.dart create mode 100644 test/common/window_test.dart create mode 100644 test/core/event_test.dart create mode 100644 test/core/interface_message_test.dart create mode 100644 test/core/lib_test.dart create mode 100644 test/database/migration_v1_to_v2_test.dart create mode 100644 test/features/overwrite_custom_views_test.dart create mode 100644 test/features/overwrite_editor_page_test.dart create mode 100644 test/features/overwrite_nested_sheet_test.dart create mode 100644 test/features/overwrite_rule_dialog_test.dart create mode 100644 test/features/overwrite_rule_form_test.dart create mode 100644 test/features/overwrite_stage_flow_test.dart create mode 100644 test/features/overwrite_view_test.dart create mode 100644 test/helpers/test_app.dart create mode 100644 test/helpers/test_database_providers.dart create mode 100644 test/helpers/test_profiles.dart create mode 100644 test/lint/dead_file_test.dart create mode 100644 test/lint/disposable_field_test.dart create mode 100644 test/lint/dynamic_message_key_test.dart create mode 100644 test/lint/icon_button_tooltip_test.dart create mode 100644 test/lint/platform_layering_test.dart create mode 100644 test/lint/ui_layer_singleton_test.dart create mode 100644 test/manager/android_manager_test.dart create mode 100644 test/manager/connectivity_manager_test.dart create mode 100644 test/manager/hotkey_manager_test.dart create mode 100644 test/manager/proxy_manager_test.dart create mode 100644 test/manager/status_manager_test.dart create mode 100644 test/manager/tile_manager_test.dart create mode 100644 test/manager/tray_manager_test.dart create mode 100644 test/manager/vpn_manager_test.dart create mode 100644 test/manager/window_manager_test.dart create mode 100644 test/models/changelog_test.dart create mode 100644 test/pages/editor_test.dart create mode 100644 test/pages/error_test.dart create mode 100644 test/pages/scan_test.dart create mode 100644 test/plugins/service_test.dart create mode 100644 test/plugins/tile_test.dart create mode 100644 test/providers/backup_action_test.dart create mode 100644 test/providers/common_action_test.dart create mode 100644 test/providers/proxies_action_test.dart create mode 100644 test/providers/setup_action_test.dart create mode 100644 test/providers/store_action_test.dart create mode 100644 test/state_run_globals_test.dart create mode 100644 test/state_run_test.dart create mode 100644 test/tool/changelog_e2e_test.dart create mode 100644 test/tool/changelog_parser_test.dart create mode 100644 test/tool/changelog_render_test.dart create mode 100644 test/views/access_test.dart create mode 100644 test/views/add_profile_view_test.dart create mode 100644 test/views/added_rules_test.dart create mode 100644 test/views/backup_and_restore_test.dart create mode 100644 test/views/connections_test.dart create mode 100644 test/views/custom_overwrite_edit_test.dart create mode 100644 test/views/dashboard/quick_options_test.dart create mode 100644 test/views/developer_view_test.dart create mode 100644 test/views/hotkey_view_test.dart create mode 100644 test/views/icon_edit_view_test.dart create mode 100644 test/views/name_add_picker_test.dart create mode 100644 test/views/network_config_test.dart create mode 100644 test/views/profile_preview_test.dart create mode 100644 test/views/proxy_providers_view_test.dart create mode 100644 test/views/requests_test.dart create mode 100644 test/views/script_content_test.dart create mode 100644 test/views/theme_test.dart create mode 100644 test/widgets/connection_item_test.dart create mode 100644 test/widgets/grid_test.dart create mode 100644 test/widgets/on_demand_test.dart create mode 100644 test/widgets/popup_test.dart create mode 100644 test/widgets/port_dialog_test.dart create mode 100644 test/widgets/sheet_height_test.dart create mode 100644 test/widgets/text_scale_notification_test.dart create mode 100644 test/widgets/uncovered_views_test.dart create mode 100644 test/widgets/update_dialog_test.dart create mode 100644 test/widgets/wave_test.dart create mode 100644 tool/changelog.dart create mode 100755 tool/check_commit_msg.sh create mode 100755 tool/check_commit_msg_test.sh create mode 100644 tool/check_coverage.dart create mode 100755 tool/check_plugins.sh create mode 100644 tool/src/changelog/builder.dart create mode 100644 tool/src/changelog/git.dart create mode 100644 tool/src/changelog/models.dart create mode 100644 tool/src/changelog/parser.dart create mode 100644 tool/src/changelog/render.dart diff --git a/.agents/architecture.md b/.agents/architecture.md index fdbd6dadd5..39a09ef0e0 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -157,6 +157,20 @@ Proxy delay testing follows the same failure-safe UI rule. `proxyDelayTest()` re real result on success, and logs plus records `-1` on exceptions. `DelayTestButton` reverses its animation in `finally`, so an RPC failure cannot leave the control permanently spinning. +## Settings Rows + +`lib/widgets/config_item.dart` holds the shared settings-row vocabulary: `ConfigToggleItem`, `ConfigOptionsItem`, +`ConfigTextItem`, and `ConfigListInputItem`. Each takes a `selector` (a `ProviderListenable`, normally +`someProvider.select(...)`) and an `onChanged(ref, value)` writer, and watches its own selector so changing one setting +rebuilds one row instead of the whole section. Titles and subtitles are `ConfigLabel` callbacks that receive +`AppLocalizations`, which keeps literal labels such as `IPv6` and localized labels in the same shape. + +Build settings screens from these directly, or from a file-local helper that binds one provider once — see `_dnsToggle` +in `lib/views/config/dns.dart` and `_appSettingToggle` in `lib/views/application_setting.dart`. Declare a named +`ConsumerWidget` only when a row is genuinely reused across screens, as `lib/views/config/network.dart` rows are by +`lib/views/dashboard/widgets/quick_options.dart`. Rows with bespoke behaviour — a custom dialog, a derived value, or a +second provider write — stay hand-written rather than growing extra parameters on the shared items. + ## State Management Provider files in `lib/providers/`: @@ -164,10 +178,155 @@ Provider files in `lib/providers/`: - `app.dart`: runtime/UI state, logs, traffic, delays, loading, navigation. - `config.dart`: persistent config providers, app settings, theme, VPN, proxy style. - `state.dart`: derived/computed providers, navigation, proxy, tray, color scheme. + Like `action.dart`, this is an entry point only: the providers live under + `lib/providers/state/` and are joined with `part` directives, so importing + `state.dart` still reaches all of them. + - `state/proxies.dart`: group and proxy lists, filter/sort, delay, selection. + - `state/navigation.dart`: navigation items, current page, dashboard, more tools. + - `state/system.dart`: tray, VPN params, access control, hot keys, shared state. + - `state/theme.dart`: dynamic color, color scheme, brightness. + - `state/profile.dart`: profiles, current profile, clash config, setup state. + - `state/overwrite.dart`: custom overwrite validity and the staged group/rule notifiers. - `action.dart`: business logic notifiers, setup, backup, core lifecycle, proxy selection. +- `core.dart`: `coreHandlerProvider`, the container-scoped handle on `CoreController`. - `database.dart`: Drift database provider wrappers. -`globalState` in `lib/state.dart` is a singleton holding app lifecycle, timers, theme, and start/stop state. Providers are generated into `lib/providers/generated/`. +### Reaching Singletons + +`lib/common/` and `lib/core/` publish process-wide singletons (`coreController`, +`system`, `preferences`, `appPath`, `request`, and others). Code that already has a +`Ref` or a `WidgetRef` reads them through a provider instead, so a test can scope a +fake to one `ProviderContainer` rather than swapping a global and relying on a +tearDown to put it back. + +`coreHandlerProvider` is the established case. Every call site under +`lib/providers/`, `lib/manager/` and `lib/views/` goes through it; notifiers and +`ConsumerState` classes that touch Core repeatedly hold it as +`CoreController get _core => ref.read(coreHandlerProvider)`. + +Tests override it with `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))`, +which does not claim the singleton. `CoreController.test` does claim it, and is +only for tests that have not moved yet. Prefer the scoped override even when a +test passes either way: a test that claims the singleton makes the global and the +provider resolve to the same fake, so it cannot tell a provider read from a +leftover global read, and a half-migrated call site stays green. + +One deliberate exception: `globalState` owns the `ProviderContainer`, so it cannot +itself live in one. Code without a `Ref` reaches providers through +`globalState.container.read(...)`. The remaining call sites are `lib/core/lib.dart`, +`lib/common/print.dart`, and the tray `read` callback in +`lib/providers/actions/system.dart` — all singletons or platform callbacks with no +`Ref` in scope. + +`lib/models/profile.dart` no longer reaches Core. `Profile.saveFile` and +`Profile.update` take a `ValidateConfig` callback, and every caller passes +`(path) => _core.validateConfig(path)`, keeping the Core handle lazy so a profile +path that never validates never resolves the controller. + +The UI layer must not reach a process-wide singleton directly. +`test/lint/ui_layer_singleton_test.dart` scans `lib/views`, `lib/widgets`, +`lib/pages` and `lib/features` for `globalState.container` and the bare +`coreController` global and fails the run on either. Widgets that need Core hold +`CoreController get _core => ref.read(coreHandlerProvider)`. + +`globalState.measure` and `globalState.theme` stay global on purpose. Both are +context-derived caches assigned by `ThemeManager`, and tests already scope them by +assigning in the app builder (see `test/helpers/test_app.dart`); moving them into +providers would touch every layout call site without changing behaviour. + +`globalState` in `lib/state.dart` is a singleton holding ambient app state — the +package info, the measure and theme, the container, and the start/stop flags — +plus `safeRun`/`loadingRun`. Startup orchestration is **not** on it: `init` and +`attach` live in `lib/bootstrap.dart`, above `lib/common`, because they drive the +window, the autostart entry, the tray and the permission prompts. Providers are +generated into `lib/providers/generated/`. + +The root navigator key lives in `lib/common/navigator.dart` as `rootNavigatorKey`; +`globalState.navigatorKey` is a getter onto it. `lib/common/dialog.dart` reaches +the key directly, so the dialog helpers no longer import `lib/state.dart`. + +### Platform Layering + +`lib/common/common.dart` deliberately does not export `tray.dart`, `window.dart`, +`launch.dart`, `system_dns.dart`, or `permission.dart`. Those five modules import +`tray_manager`, `window_manager`, `launch_at_startup`, and `screen_retriever`; +exporting them put those packages in the compile graph of all 132 files that +import the barrel for a string helper. Import the specific module instead. + +`test/lint/platform_layering_test.dart` enforces four rules. Three are local: the +barrel never re-exports one of those five modules, nothing under `lib/common`, +`lib/enum` or `lib/models` other than those five imports a desktop platform +package, and `lib/common` never imports the `lib/manager/manager.dart` barrel +(import the single manager needed, as `common/context.dart` does with +`manager/status_manager.dart`). The fourth walks the barrel's whole transitive +closure and fails if *any* file in it imports one of those packages. That one is +the real invariant — the local rules only stop the shortest path, and every leak +found so far arrived through a longer one. + +Four consequences are already in the tree: + +- `System.back` and `System.exit` no longer touch `window`; the window half of + both lives in `SystemAction`. +- `KeyboardModifier.toHotKeyModifier()` moved from `lib/enum/enum.dart` to + `lib/manager/hotkey_manager.dart`, its only consumer. +- Startup orchestration moved off `GlobalState` into `lib/bootstrap.dart`. + `common/num.dart`, `common/print.dart` and `common/request.dart` import + `state.dart` for `theme`, `container` and `packageInfo`/`ua`, so anything + `GlobalState` reaches lands in the barrel's closure; the ambient state it now + holds reaches nothing platform-specific. +- `SystemAction` talks to `WindowPort` and `TrayPort` from + `lib/common/app_ports.dart` instead of importing `common/window.dart` and + `common/tray.dart`. `lib/bootstrap.dart` binds `windowPort` and `trayPort` to + the real implementations; both stay null in tests, where every call through + them is a no-op. A test that needs the real tray assigns `trayPort` itself, as + `test/common/tray_menu_test.dart` does. + +Narrow the barrel imports too: `lib/providers/providers.dart` re-exports +`action.dart`, so importing the providers barrel from `lib/common` or from +`providers/app.dart` reaches the whole action layer. Those three now import +`providers/state.dart` and `providers/config.dart` directly. + +The same shape appeared twice more, without a platform package involved: a data +type in a lower layer holding the widget that renders it, which drags the whole +view tree into the barrel's closure. + +- `lib/common/navigation.dart` was a route table building view widgets. It is + now `lib/views/navigation.dart` implementing `NavigationPort`, which + `providers/state/navigation.dart` reads through and `bootstrap.init` binds. + Unbound it yields no items, so a test that renders navigation assigns + `navigationPort` itself, as `test/pages/home_test.dart` does. +- `DashboardWidget` carried a `GridItem` per value, so `lib/enum/enum.dart` + imported the dashboard cards — and `lib/widgets/widgets.dart` with them. The + enum is persisted in the app settings, so it is back to plain data; the + mapping lives in `lib/views/dashboard/widget_registry.dart`, the reverse + lookup relying on the branches returning canonical consts. + +Together those took the closure from 258 files to 187, with nothing under +`lib/views` left in it. `test/lint/platform_layering_test.dart` pins that +directly: the barrel's closure must contain no `lib/views` file. Data the +provider layer needs from the UI layer goes through a port in +`lib/common/app_ports.dart` rather than an import in the other direction. + +### High-Frequency Buffers + +`logsProvider`, `requestsProvider` and `trafficsProvider` hold a `FixedList` +(`lib/common/fixed.dart`), which trades a normal copy-on-write for a shared +buffer tagged with a generation counter: + +- `append` mutates the buffer in place and returns a new wrapper one generation + ahead. That is what providers publish, so `updateShouldNotify` still fires. +- `list` returns an immutable copy, cached until the next mutation. It must stay + eager: an older wrapper shares the buffer, so its contents move on. Anything + that needs a stable view has to read `list` at the moment it is notified, not + hold the wrapper and read later. +- Consumers that only need to know *that* the buffer changed watch `revision`, + not `list` — selecting on the list snapshots and deep-compares the whole + buffer on every arrival, which is what this design exists to avoid. See + `lib/views/logs.dart` for the pattern: watch the generation, snapshot inside + the throttled callback. + +`add`/`clear` mutate in place without advancing the generation; use them only on +a buffer you own outright (seeding, resets, tests), never on published state. ## Database @@ -188,16 +347,31 @@ Generated Drift output lives in `lib/database/generated/database.g.dart`. After ## Manager Stack -Managers are nested `InheritedWidget`/`StatefulWidget` components in `lib/application.dart`: +Managers are nested `InheritedWidget`/`StatefulWidget` components built by `buildManagerStack()` in `lib/application.dart`: ```text AppEnvManager > StatusManager > ThemeManager > [Desktop: WindowManager > TrayManager > HotKeyManager > ProxyManager] - > ConnectivityManager > CoreManager > AppStateManager - > [Mobile: AndroidManager > VpnManager | Desktop: WindowHeaderContainer] + [Mobile: AndroidManager > TileManager] + > AppStateManager > CoreManager > ConnectivityManager + > [Desktop: WindowHeaderContainer] [Mobile: VpnManager] + > app content ``` -Each manager in `lib/manager/` handles a specific platform concern. Desktop-only managers are conditionally inserted. +Each manager in `lib/manager/` handles a specific platform concern. The +platform slots are exclusive: no desktop manager appears on mobile and no mobile +manager appears on desktop. + +The order is an ownership contract, not a layout detail. `ConnectivityManager` +sits below `CoreManager` because its `onConnectivityChanged` callback reads +Core-backed state, so Core must already be mounted when it fires. `StatusManager` +and `ThemeManager` sit above the platform managers so a platform manager can +surface a message or read the theme. + +`buildManagerStack()` is a pure function of `isDesktop`, the connectivity +callback, and the app content, so `test/application_test.dart` asserts the whole +order by constructing the stack without mounting it. Changing the nesting means +updating both this diagram and that test. ## Core Controller and Actions @@ -329,6 +503,10 @@ Windows helper integrity/version check: - `/stop` requires the same session ID. A missing process returns `notRunning`; a different owner returns `sessionMismatch` without terminating that process. Session IDs are ownership tokens for lifecycle safety, not a claim that the loopback HTTP endpoints are authenticated. +- Never take `MANAGED_CORE` or `LOGS` with `lock().unwrap()`. The Helper is a long-lived service running as SYSTEM, so a + single panic while a lock is held would poison it and turn every later request into another panic — the service stays + dead until Windows restarts it. `lock_surviving_poison` recovers the guard through `PoisonError::into_inner` instead. + `hub.rs` uses it at every lock site, tests included, and two tests in that file pin the behaviour. Build configuration defaults live in `build_tool/lib/src/options.dart` and can be overridden via a root `build_config.yaml`. @@ -339,7 +517,7 @@ Architecture detection is automatic. The `--description` flag passed to `flutter - `setup`: build-time harness for Go core artifacts and the Windows Rust helper; no runtime Dart API. - `proxy`: system proxy configuration. - `rust_api`: runtime Flutter Rust Bridge FFI plugin built through Cargokit. -- `tray_manager`: system tray fork/customization. +- `tray`: system tray for Linux, macOS and Windows. Written for FlClash; replaced the `tray_manager` fork. - `wifi_ssid`: Wi-Fi SSID detection. - `window_ext`: window extensions. - `flutter_distributor`: app packaging/distribution. diff --git a/.agents/commands.md b/.agents/commands.md index 9d9154799d..dd88009482 100644 --- a/.agents/commands.md +++ b/.agents/commands.md @@ -87,7 +87,7 @@ flutter test test/setup_test.dart flutter test plugins/proxy/test/proxy_test.dart ``` -Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory. Native plugin tests under platform folders are not run by `flutter test`. +Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`. For the current Core/service architecture, useful focused checks are: @@ -142,6 +142,50 @@ JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradl Always-on VPN entry, system VPN revoke, actual permission UI, and rapid device start/stop still require Android device or emulator validation; Kotlin compilation cannot prove those system callbacks. +## Changelog And Release + +The changelog is derived from Conventional Commits by `tool/changelog.dart` and written to two committed files: +`CHANGELOG.md` for readers and `changelog.json` for the renderers. See `.agents/rules.md` for the `Changelog:` trailers +that decide the wording. + +The app ships no changelog of its own. `render release` appends the released version as JSON inside an HTML comment +(``), so the release body GitHub already returns to `checkForUpdate` carries the +notes shown in the update dialog. `parseReleaseChangelog` reads that block and falls back to the English +bullets when a release predates it. + +```bash +dart run tool/changelog.dart verify # what CI checks +dart run tool/changelog.dart release --version 0.8.96 +dart run tool/changelog.dart build --unreleased # changelog.json only, includes untagged work +dart run tool/changelog.dart render release --out release.md +dart run tool/changelog.dart render telegram --out telegram.md +``` + +Releasing a stable version, in order: + +```bash +tool/bump_version.sh all +dart run tool/changelog.dart release --version 0.8.96 +git commit -am "chore(release): v0.8.96" +git tag v0.8.96 && git push --follow-tags +``` + +The release commit comes before the tag on purpose: the generated wording is reviewable in the diff before it ships, and +the tag is what `render release` reads. CI never writes back to the repository; it only runs `verify`. Wording in +`changelog.json` may be edited by hand as long as no derivable entry disappears and every entry still points at a commit +inside that version's range. + +Entries at or below `v0.8.95` are frozen: they predate the pipeline, live under the `` marker +in `CHANGELOG.md`, and are never regenerated. + +`verify` compares a version only when its tag is reachable from `HEAD`, because that is the same scope the builder walks +(`git tag --merged`). A branch cut before the newest release cannot derive that version at all, so `verify` names it as +skipped and moves on instead of reporting drift that does not exist. Checking mere tag existence is what made every such +branch fail on an unrelated release. + +Prerelease tags (`v0.8.96-pre.N`) skip the release commit, and CI renders their notes with `build --unreleased` for the +Telegram post. They publish no GitHub release, so the update dialog never sees them. + ## Verify The tag-triggered release workflow runs these root-package checks in order: @@ -149,7 +193,9 @@ The tag-triggered release workflow runs these root-package checks in order: ```bash flutter pub get flutter analyze --no-fatal-infos +dart run tool/changelog.dart verify flutter test --reporter expanded +bash tool/check_commit_msg_test.sh ``` Run `flutter analyze` locally before committing when practical. @@ -160,3 +206,9 @@ plugin packages, so CI also validates local Flutter packages, the setup build tool, the Go wrapper, and Rust components from their own package directories. A separate Windows runner compiles and tests the helper's `windows-service` feature before release builds can start. + +`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script. +It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs +`flutter test` wherever `test/*_test.dart` exists. Adding a plugin package needs +no workflow edit; enumerating packages by hand in the workflow is what +previously left `plugins/tray` unanalyzed and untested. diff --git a/.agents/project.md b/.agents/project.md index dd63b87b7f..d7ca427aec 100644 --- a/.agents/project.md +++ b/.agents/project.md @@ -8,6 +8,96 @@ FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with version as the source of truth for release builds. - Dart SDK constraint: `>=3.8.0 <4.0.0`. +## Forked Dependencies + +Three `pubspec.yaml` dependencies are pinned to a fork by commit SHA. All three +forks live under `chen08209`, the same account that owns this repository, so they +are maintained in-house rather than tracked from a third party: advancing a pin +is a local decision, and there is no external maintainer to wait on for the patch +itself. What each fork still waits on is the *upstream* fix that would let the +pin be dropped entirely, recorded below. + +Each entry records what the fork changes and what has to be true before it can go +back to the published package, so a future upgrade does not have to rediscover it. +Re-verify a fork by diffing its pub cache checkout against the published version +of the same number: + +```bash +diff -ru ~/.pub-cache/hosted/pub.dev/- ~/.pub-cache/git/- +``` + +`window_manager` — `chen08209/window_manager`, path `packages/window_manager`, +version 0.5.1. + +- Changes `windows/window_manager_plugin.cpp` only. With `titleBarStyle: hidden` + a maximized window uses the monitor work area (`GetMonitorInfo().rcWork`) + instead of upstream's `adjustNCCALCSIZE` border fudge, so the maximized window + no longer covers the taskbar. +- Drop the fork once upstream constrains a hidden-title-bar maximized window to + the work area on Windows. Dart API is untouched, so nothing in `lib/` changes. + +`launch_at_startup` — `chen08209/launch_at_startup`, version 0.5.1. + +- Migrates `win32_registry` from `^2.0.0` to `^3.0.3`, which is a breaking rename + across the whole Windows implementation (`Registry.openPath` → `CURRENT_USER.open`, + `createValue` → `setValue`, `getStringValue` → `getString`). +- This one is not optional while it lasts: FlClash depends on `win32_registry: ^3.0.3` + directly, and upstream's `^2.0.0` constraint cannot co-resolve with it. +- Drop the fork when upstream publishes a release that accepts `win32_registry` 3.x. + +`yaml_writer` — `chen08209/yaml_writer`, version 2.1.0. + +- Adds `StringNode.quoteKey()` and applies it to map keys in `lib/src/node.dart`. + Upstream quotes values but emits keys verbatim, so a profile key needing quotes + is written as invalid YAML. +- Drop the fork once upstream quotes map keys by the same + `isValidUnquotedString` rule it already applies to values. + +## Dependency Ceilings + +Several dependencies cannot be advanced from this repository, and re-running +`flutter pub outdated` will keep listing them. The blocker is upstream in every +case, so treat the list as resolved-until-the-ceiling-moves rather than as debt: + +`freezed` is pinned exactly to `3.2.6-dev.1`, which is a pre-release *ahead* of +the newest stable `3.2.5`. It is not a stale pin and must not be "fixed" by +moving to `3.2.5`: stable `3.2.5` requires `analyzer >=9.0.0 <11.0.0`, while the +pinned Flutter SDK resolves `analyzer` 12. `3.2.6-dev.1` is the only published +freezed release that accepts `analyzer` 12. Move to a stable release only once +one exists that accepts the analyzer the SDK actually resolves. + +One `analyzer` ceiling holds most of the remaining `flutter pub outdated` list. +The newest `build_runner`, `drift_dev`, `intl_utils`, `test`, and +`riverpod_generator` all require `analyzer` 13; `test` 1.31.2 additionally +requires `test_api` 0.7.13, while `flutter_test` from the pinned SDK depends on +`test_api` 0.7.11. Raising any of those bounds therefore fails version solving. + +The `riverpod` chain is the visible symptom. It is held at +`riverpod`/`flutter_riverpod` 3.3.2, `riverpod_annotation` 4.0.3, and +`riverpod_generator` 4.0.4 as one rigid unit, because `riverpod_annotation` +4.0.3 depends on `riverpod` exactly 3.3.2: the runtime cannot move without the +generator, and the generator cannot move without `analyzer` 13. Retry the whole +set after a Flutter SDK bump raises `test_api`, not before. + +`intl` is intentionally unbounded (`any`) and `material_color_utilities` is +resolved by the SDK; neither is a bound this repository sets. + +`isolate_contactor` is discontinued and `isolate_manager` is several majors +behind. Both arrive through `re_editor`, which pins `isolate_manager: ^4.1.5+1` +and is already at its own latest release. Nothing in this repository can advance +them. + +`CorePalette` is deprecated in `material_color_utilities` in favour of +`DynamicScheme`/`CorePalettes`, but `dynamic_color` 1.9.0 — its newest release — +still exposes only `DynamicColorPlugin.getCorePalette()`, which returns the +deprecated type. There is no migration available from this repository short of +calling the `io.material.plugins/dynamic_color` method channel directly and +decoding the palette int list by hand, which is not worth owning. The deprecated +type is therefore confined to `GlobalState._initDynamicColor`, which converts it +to the two plain seed colours the app actually consumes; everything downstream +sees `DynamicColorSeeds`. Revisit when `dynamic_color` ships a non-deprecated +accessor — only that one function has to change. + ## Build Dependencies Linux: diff --git a/.agents/rules.md b/.agents/rules.md index abf4935b9c..10f34f3d7a 100644 --- a/.agents/rules.md +++ b/.agents/rules.md @@ -4,7 +4,11 @@ These are repository coding and testing conventions. Codex command permission ru ## Dart and Flutter Style -`analysis_options.yaml` enforces these non-default rules: +The lint set lives in `lint_options.yaml` at the repo root. The root `analysis_options.yaml` and every local plugin under +`plugins/*` include it, so application and plugin code are held to the same rules. Add or change a rule there, not in an +individual `analysis_options.yaml`; those files carry only their own `analyzer.exclude` entries. + +`lint_options.yaml` enforces these non-default rules: - `prefer_single_quotes: true`: always use single quotes. - `require_trailing_commas: true`: use trailing commas in multi-line argument lists. @@ -13,6 +17,38 @@ These are repository coding and testing conventions. Codex command permission ru - `prefer_const_constructors: true` and `prefer_const_declarations: true`. - `prefer_final_locals: true` and `prefer_final_in_for_each: true`. - `always_declare_return_types: true`. +- `only_throw_errors: true`: throw an `Exception` or `Error`, never a bare `String`. + +Failures whose whole content is a message meant for the user throw +`MessageException` from `lib/common/exception.dart`. Its `toString()` is the bare +message, which is what `globalState.safeRun` surfaces in the dialog, so the +user-facing text is unchanged from the older `throw someMessage` idiom while the +throw stays catchable as an `Exception` and carries a stack trace. Assert on it +with `isA().having((e) => e.message, 'message', ...)`, not on a +raw string. + +### Corner Radius + +Rounded corners are superellipses everywhere, not circular arcs. Use the superellipse API at each layer: + +- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`. +- Clips: `ClipRSuperellipse` instead of `ClipRRect`. +- Container decorations: `ShapeDecoration(shape: RoundedSuperellipseBorder(...))` instead of + `BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with + `clipBehavior` still clips to the shape path. +- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`. + +Passing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only +carries the corner magnitude; the rendered geometry stays a superellipse. + +APIs that accept only `BorderRadius` keep circular corners, with the superellipse supplied by an +enclosing clip or shape where one is needed: `InkWell.borderRadius`, `OutlineInputBorder`, +`ScrollbarThemeData.radius`, and `smooth_sheets`' `MaterialSheetDecoration`. Fully round pills +(`BorderRadius.circular(999)` or half the shortest side) may stay circular — both geometries coincide +there. + +CI gates formatting: `dart format --output=none --set-exit-if-changed lib test +tool plugins setup.dart` runs before `flutter analyze`. Generated directories are excluded from analysis: @@ -52,7 +88,7 @@ Pick the destination by where the constraint would be violated, not by how impor that. - **A fact that is true only at one call site, and is not visible from that call site, stays a comment there.** Its value is being in the reader's line of sight at the moment of the edit. `lib/common/constant.dart` is the model case: - the delay-test concurrency cap is bound to `mBatch` in `core/common.go`, and whoever changes that number must see the + the delay-test concurrency cap is bound to `delayTestConcurrency` in `core/common.go`, and whoever changes that number must see the constraint on the same screen. Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line; @@ -63,12 +99,88 @@ the invariant hard to break beats prose that asks the next reader not to break i ## Core API Safety +- `core/Clash.Meta` is a fork of mihomo, and changes to it are budgeted for features, not repairs. Fixing a bug there is + low priority even when the bug is real and the fix is small: every patch is one more thing to carry across an upstream + rebase. Solve it on the FlClash side of the boundary and note the mihomo behaviour you are working around. Reach into + the submodule only for a feature that has nowhere else to live, or when the problem is one the FlClash patches + themselves introduced — and say which of the two it is in the commit message. - Do not expose direct filesystem deletion APIs through Core or helper IPC; use a scope-specific cleanup API instead. - Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, JNI, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches. -- Keep high-volume log/request events separate from state-bearing events in `core/message.go`; bulk backpressure must not - evict delay, loaded-provider, or geo-update state. +- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update), delay, and + bulk (log, request). Delay and bulk evict their own oldest entry under backpressure; state uses `enqueueState`, which + never evicts, because a dropped `geoUpdate{updating:false}` leaves `isUpdatingProvider` stuck at true in the UI until + the app restarts. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full + queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports, + and reporting it from the message layer feeds the same batcher. +- `jni_get_string` in `android/core/src/main/cpp/jni_helper.cpp` `malloc`s and hands ownership to Go, which frees through + `free_string_func`. `quickSetup` relies on that: it reads its `*C.char` arguments inside a goroutine, after the JNI + wrapper has already returned. Switching the wrapper to `GetStringUTFChars`/`ReleaseStringUTFChars`, or freeing on the + C side, turns that read into a use-after-free. +- Go goroutines reach Java through `ATTACH_JNI()`, which attaches once with `AttachCurrentThreadAsDaemon` and detaches + from a `pthread_key` destructor at thread death. Do not restore a detach-per-call: `protect` runs once per outbound + socket and `onResult` once per event batch, and attach/detach takes ART's thread-list lock each time. +- Every JNI call into Kotlin must be followed by `jni_clear_exception`. A pending exception left in place aborts the + process on the next JNI call on that thread, so a throw in `protect`/`resolverProcess`/`onResult` becomes a crash in + unrelated code. +- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to + the log subscriber, batched, and handed back to `send`, so a send failure reported that way feeds itself; use + `logDeliveryError`, which writes to stderr and latches until a frame gets through or the next connection is installed. + A write that fails without putting a byte on the wire — host backpressure hitting `ipcWriteTimeout`, or a payload above + `maxIPCFrameSize` — drops that one frame and keeps the connection: the stream is still framed correctly, and tearing it + down here ends the read loop, and with it the Core process. Only a half-written frame desynchronizes the stream, and + that is the one case `send` closes on. +- Core method handlers in `core/hub.go` are synchronous. Anything that must not block the dispatcher is spawned by + `safeGo`/`safeGoDetached` in `core/method.go`, which recover; a bare `go` in a handler puts a panic outside every + recovery and kills the process, which on Android is the whole application. The `//export` entry points in + `core/lib.go` do not reach `handleMethodCall`, so each one carries its own recovery. +- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in + `core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the + socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func + value. Stopping the TUN swaps `activeTunHandler` instead. +- `tunnel.AllProxies()` returns a shared, cached map — never modify it. The cache is invalidated by + `invalidateAllProxies` on `tunnel.UpdateProxies` and validated against each provider's `Version()`, so a rebuild + costs one read per provider rather than one per proxy. Anything else added to `tunnel/patch.go` that derives from the + proxy set needs both signals: the external controller can reload the config through `hub/route/configs.go` without + going through FlClash's `applyConfig`, so a hook on the FlClash side alone would miss a profile switch. +- Core state that mirrors mihomo state goes stale at the next `applyConfig`, which replaces every proxy, provider and + rule. Read the tunnel instead of caching a snapshot of it: `lookupExternalProvider` kept one that was rebuilt only + when the host asked for the provider list, and the host asks after a successful setup and not after a failed one, so + an update ran against a provider the tunnel no longer held — downloading, writing to disk and reporting success + against nothing. +- Selection writes take `selectMu`, not `configMu`. mihomo's `Selector.Set` has no lock of its own, so the writes need + mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is + what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under + `configMu`, fixing the order as `configMu` → `selectMu`. +- The delay-test semaphore is acquired with a slice of the caller's budget (`budget/delayTestQueueShare`), not + unconditionally and not with the whole deadline. Queueing and probing come out of one budget, so a test handed all of + it can spend it waiting and reach `URLTest` with nothing left, reporting a proxy it never contacted as unreachable. + The probe keeps the caller's original deadline, so whatever the queue did not use is still its own. +- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy: + `handleTestDelay` returns inside its own budget on every path. `asyncTestDelay` therefore returns null instead of a + `-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a + timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's. +- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0 + used to mean "testing", which let a result and the state of a test overwrite each other and left cards spinning + forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a + reply arriving; core status leaving `connected` cancels every run in flight. +- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background + scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the + batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData` + claims per resource and `updater.GeoUpdateHook` releases. +- A failed `applyConfig` rolls the tunnel back to the default config, and that rollback is the whole recovery: + `handleSetupConfig` returns the error and stops there. Do not add a teardown on top of it — stopping the listeners + takes the app offline over a profile the user can still switch away from, and the error already reaches the host, + which is what surfaces the failure (`MessageException` on the Flutter side, the config-error toast on Android). Keep + an empty `config.yaml` out of the failure path — it is how the app says "no profile selected", and `loadConfig` + resolves it to the defaults. +- Package `init` in the Android library runs while the `.so` is being loaded, so a panic there takes the application + down before it can report anything. `platform/limit.go` arms an fd-pressure probe and degrades to never blocking when + it cannot; keep that shape for anything else `init` sets up that correctness does not depend on. +- Every `android && cgo` file in `core/` is compiled only by the NDK-backed CI step in the `test` job. Keep the build + constraints as `android && cgo` / `!(android && cgo)`: a bare `cgo` constraint makes `go build ./...` fail in `core/` + on any developer machine, because the files it pulls in need the NDK. ## Lifecycle Rules @@ -84,18 +196,114 @@ the invariant hard to break beats prose that asks the next reader not to break i broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation. - Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately. +- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to "`show` was never called". + `AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins. + Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)` + means `WM_DESTROY` never runs. +- The `tray` plugin owns call ordering, idempotency, serialization, and unchanged-payload suppression. Application code + declares desired state through one `Tray.show(TraySpec)` call and must not add platform branches to work around + ordering. Platform branches in `lib/common/tray.dart` are only for deliberate product differences (macOS speed title + and group submenus); query `Tray.instance.capabilities` for ability differences. +- Every native `show` returns whether the tray now reflects the payload, and reports `false` instead of showing a broken + icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather + than suppressed until restart. Any test that mocks the `tray` channel must return `true` from `show`. +- The delayed DNS re-check `NetworkObserveModule.onLosing` posts is deliberately left un-deduplicated. The runnable + re-reads `networkInfos` and does nothing when `onLost` already dropped the network, `updateDns` returns early when the + resolved list is unchanged, and `stop()` clears the handler queue, so a network that reports `onLosing` repeatedly + costs one comparison per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds + state that has to stay in sync with the map. ## Testing Rules The `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage -collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper; -verify cross-language protocol behavior through shared Dart contract tests under `test/core/` and native platform build -checks. +collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper, +plus an NDK-backed `GOOS=android` vet that covers the `android && cgo` files the first two exclude; verify cross-language +protocol behavior through shared Dart contract tests under `test/core/` and native platform build checks. + +A Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` — +leaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever +connection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with +`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test +connection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches +it in an unrelated test. Use `CoreController.test(mock)` to inject a mocked `CoreHandlerInterface`. Call `CoreController.resetInstance()` in `tearDown` to clean up the singleton between tests. Register fallback values for freezed params used with `any()` matchers. +`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a +group's floor when new tests lift it; do not lower one to make a run pass. + +Every measured group needs a floor. A group the report measures but `_groupFloors` does not declare fails the run, so +adding a top-level directory under `lib/` means adding its floor in the same change. Set a new floor at or just below +the coverage the directory actually has; the point is to stop a slide, not to backfill tests before the directory can +land. + +Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and +touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read +resolve to the same fake, so it cannot fail on a call site that still reaches for the global. + +Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid` +and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every +path. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`. + +Three globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it: +`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every +`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry +key), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early +under `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test +at all; `test/common/launch_test.dart` pins the early return instead. + +`pumpAndSettle` never returns on a page holding `EditorPage`: the code editor blinks its caret forever, so frames keep +being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in `common/task.dart` hand work to a real +isolate through `compute`, which only runs outside the fake-async zone, so a test awaiting one needs +`tester.runAsync(...)` between the pumps — see `test/views/profile_preview_test.dart`. + +The `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it +replaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the +setter is not, so closing there would either be unawaited or force the seam to become async for no gain. A test that +does open a file-backed database owns closing it. + +`system.isAndroid` / `isMacOS` / `isWindows` / `isLinux` read `dart:io` `Platform` and cannot be overridden, unlike +`debugDefaultTargetPlatformOverride`. A branch behind one of them is only ever exercised on a host that matches it, so CI +(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior, +and leave headroom under a group floor that covers such a branch. + +Auto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`, +`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching +rebuilds the provider from its override and silently discards whatever the code under test wrote. Add +`container.listen(theProvider, (_, _) {})` in the harness, as `overwrite_stage_flow_test.dart` does. The staging flow also +re-arms its debounce when it clears the stage, so drain it (`pump` past the duration, then unmount) or the binding fails +the test on a pending timer. + +A field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`, +`PageController`, `AnimationController` or `StreamController` must be released in the same file. +`test/lint/disposable_field_test.dart` enforces this by scanning `lib/`, because no lint covers it: `close_sinks` only sees +sinks, and nothing in the standard set tracks `ChangeNotifier` disposal. A field that genuinely outlives its owner goes in +that test's `_allowed` set with the reason, not left bare. Controllers received as widget parameters belong to the caller +and are out of scope. + +An `IconButton` whose icon is an icon needs a `tooltip`. It is the button's only accessible name — without it TalkBack and +VoiceOver announce nothing and the desktop build shows no hover hint. `test/lint/icon_button_tooltip_test.dart` enforces +it and skips exactly two shapes: an `icon:` holding a `Text`, which is already a visible label, and +`views/dashboard/widgets/core_status_button.dart`, which takes its label from an enclosing `Tooltip` (a second test fails +if that wrapper disappears). Reuse an existing string before adding one; a label that depends on state goes on the button +inside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden +behind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern. + +A public top-level declaration that nothing outside its own file references is dead, and no lint catches it: +`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every +"unused import" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the +`final appPath = AppPath()` singletons next to them — appear nowhere else, counting generated code as a consumer (a +riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or +typedefs are skipped: those are reached through the types they attach to, never by name. + +A `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then +immediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown +throws "`…State.dispose failed to call super.dispose.`" in debug and profile builds. Declare the override as `void +dispose()` and hand async teardown to `unawaited(...)`; `Future dispose() async` compiles and is the shape that +invites the bug. + Use `ProviderContainer` directly for simple Riverpod provider tests. The generated Riverpod `update()` method takes a callback: ```dart @@ -107,6 +315,53 @@ When testing freezed models with nested objects, always round-trip through `json For async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success, failure, disposal, and any timer boundary that changes visible state. +## Commit Messages + +Subjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs +`tool/check_commit_msg.sh`: + +```text +[(scope)][!]: +``` + +- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`. +- Scope is optional and lower case; use a comma to list several, as in `fix(core,android)`. +- `!` before the colon marks a breaking change. +- Descriptions start in lower case, omit the trailing period, and keep the whole subject within 100 characters. + Identifiers and acronyms keep their own casing, as in `fix(ui): AppBar text is truncated`. +- `Merge`/`Revert` subjects and `fixup!`/`squash!` commits are exempt. +- No `Co-authored-by` trailer crediting a coding agent, whatever that tool's own convention says. The history + records who owns the change, not which tool typed it; human co-authors are still fine. The hook rejects the + known agent identities. + +Write what the change does, not that something changed: `perf(views): stop redoing per-frame work in build`, not +`Optimize more details`. + +Install the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`. + +### Changelog Trailers + +`tool/changelog.dart` builds the user facing changelog from the commit history, so the trailers below are the copy that +ships to users. The subject stays the developer facing summary and is only the fallback. + +```text +feat(profiles): support per-profile override script + +Changelog: Per-profile override scripts +``` + +- `Changelog:` is the English entry. `Changelog: skip` drops the commit from the changelog entirely. +- The changelog is English only. Translation trailers were removed on purpose: they pushed release copy into the commit + history, so any `Changelog-:` or `Breaking-:` now fails the hook. Translate after the fact if ever + needed, not in the commit message. +- `Changelog-Type:` moves an entry into another group, for example to promote a `refactor` that users will notice. Valid + values are `breaking`, `feat`, `fix`, `perf`, `revert`. +- `BREAKING CHANGE:` is required whenever the subject carries `!`, and its text becomes the breaking entry. A `!` commit + therefore needs two lines of copy: the footer for the breaking entry and `Changelog:` for the normal one. + +`feat`, `fix`, `perf`, `revert` and breaking commits are collected by default; every other type is dropped unless it +carries a `Changelog:` trailer. Commits missing a trailer reuse their subject, and the hook says so without blocking. + ## Generated Code Do not manually edit generated files under: @@ -118,3 +373,14 @@ Do not manually edit generated files under: - `lib/l10n/intl/` After schema, model, or provider changes, run build generation and include focused tests when behavior changes. + +Strings live in `arb/intl_{en,zh_CN,ja,ru}.arb` — flat JSON, no `@` metadata. Add a key to all four, then regenerate with +`dart run intl_utils:generate`, which rewrites `lib/l10n/`. A key present in only some locales silently falls back to +English at runtime, so add the translation rather than leaving it out. + +Some labels are not reached through the generated `AppLocalizations` getters at all. `Intl.message()` +builds the key from an enum name or a stored string — `action_${HotAction.name}`, `${DynamicSchemeVariant.name}Scheme`, +`NavigationItem.description`. The analyzer sees nothing, and a failed lookup returns the key itself, so a stale key ships +as `routeMode_config` in the UI rather than throwing. `test/lint/dynamic_message_key_test.dart` expands those families +from the real enums and fails when a derived key is missing from any locale; every `Intl.message` site in `lib` must be +registered there, so a new dynamic key cannot be added without also declaring what builds it. diff --git a/.github/scripts/generate_release_notes.sh b/.github/scripts/generate_release_notes.sh deleted file mode 100644 index 25fa71a6e9..0000000000 --- a/.github/scripts/generate_release_notes.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -previous_tag="${1:-}" -output="${2:-release.md}" - -: > "$output" - -append_range() { - local range="$1" - - git log --no-merges --pretty=format:'%B' "$range" | - awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$output" -} - -current_tag="" -while IFS= read -r next_tag; do - if [[ -n "$current_tag" ]]; then - [[ "$current_tag" == "$previous_tag" ]] && break - append_range "$next_tag..$current_tag" - fi - current_tag="$next_tag" -done < <(git tag --merged HEAD --sort=-creatordate) - -if [[ -n "$current_tag" && "$current_tag" != "$previous_tag" ]]; then - append_range "$current_tag" -fi diff --git a/.github/scripts/generate_release_notes_test.sh b/.github/scripts/generate_release_notes_test.sh deleted file mode 100644 index 82a683afb8..0000000000 --- a/.github/scripts/generate_release_notes_test.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -generator="$script_dir/generate_release_notes.sh" -temp_dir="$(mktemp -d)" -trap 'rm -rf "$temp_dir"' EXIT - -repo="$temp_dir/repo" -git init --quiet --initial-branch=main "$repo" -git -C "$repo" config user.email "release-notes-test@example.com" -git -C "$repo" config user.name "Release notes test" - -commit() { - local message="$1" - local date="$2" - - GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ - git -C "$repo" commit --allow-empty --quiet --message "$message" -} - -commit "Initial release" "2025-01-01T00:00:00Z" -git -C "$repo" tag v1.0.0 - -commit "First release change" "2025-01-02T00:00:00Z" -commit "Update changelog" "2025-01-03T00:00:00Z" -git -C "$repo" tag v1.1.0 - -git -C "$repo" branch feature -git -C "$repo" checkout --quiet feature -commit $'Feature branch change\n\nFeature detail' "2025-01-04T00:00:00Z" -git -C "$repo" checkout --quiet main -GIT_AUTHOR_DATE="2025-01-05T00:00:00Z" \ - GIT_COMMITTER_DATE="2025-01-05T00:00:00Z" \ - git -C "$repo" merge --no-ff --quiet --message "Merge feature" feature -git -C "$repo" tag v1.2.0 - -expected="$temp_dir/expected.md" -actual="$temp_dir/actual.md" -printf '%s\n' \ - "- Feature branch change" \ - "" \ - "- Feature detail" \ - "" \ - "- First release change" \ - "" > "$expected" - -( - cd "$repo" - bash "$generator" v1.0.0 "$actual" -) -diff -u "$expected" "$actual" - -printf '%s\n' "stale content" > "$actual" -( - cd "$repo" - bash "$generator" v1.2.0 "$actual" -) -[[ ! -s "$actual" ]] - -( - cd "$repo" - bash "$generator" "" "$actual" -) -grep -q -- "- Initial release" "$actual" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0078a1e476..d8600e8d56 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -2,8 +2,15 @@ name: build on: push: + branches: + - '**' tags: - 'v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }} + env: IS_STABLE: ${{ !contains(github.ref, '-') }} FLUTTER_VERSION: '3.44.4' @@ -17,10 +24,11 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive + fetch-depth: 0 - - name: Test release notes generation + - name: Test commit message hook shell: bash - run: bash .github/scripts/generate_release_notes_test.sh + run: bash tool/check_commit_msg_test.sh - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -32,34 +40,25 @@ jobs: - name: Install dependencies run: flutter pub get + - name: Check format + run: dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart + - name: Analyze run: flutter analyze --no-fatal-infos + - name: Verify changelog + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') + run: dart run tool/changelog.dart verify + - name: Run tests - run: flutter test --reporter expanded + run: flutter test --reporter expanded --coverage + + - name: Check coverage + run: dart run tool/check_coverage.dart coverage/lcov.info 75 - name: Validate local Flutter packages shell: bash - run: | - for package in plugins/proxy plugins/wifi_ssid plugins/window_ext plugins/setup; do - ( - cd "$package" - flutter pub get - flutter analyze --no-fatal-infos - ) - done - - - name: Run local plugin tests - shell: bash - run: | - ( - cd plugins/proxy - flutter test --reporter expanded - ) - ( - cd plugins/wifi_ssid - flutter test --reporter expanded - ) + run: bash tool/check_plugins.sh - name: Validate setup build tool working-directory: plugins/setup/buildkit/build_tool @@ -76,11 +75,73 @@ jobs: - name: Validate Go core wrapper working-directory: core + shell: bash env: CGO_ENABLED: 0 run: | - go test . + # `go test` succeeds silently when a package has no test files, so + # assert the wrapper actually ships tests before trusting the result. + if ! compgen -G '*_test.go' > /dev/null; then + echo 'No Go tests found in core/; the test step would pass vacuously.' >&2 + exit 1 + fi + test -z "$(gofmt -l ./*.go ./platform ./tun)" || { + gofmt -l ./*.go ./platform ./tun >&2 + exit 1 + } + go test -count=1 . go vet . + # `go vet .` only reaches the root package. `platform` and `tun` are + # behind build constraints, so they need an explicit GOOS to compile + # at all; without this they ship unvetted. + GOOS=linux go vet ./platform + + - name: Setup NDK + uses: nttld/setup-ndk@v1 + id: core-ndk + with: + ndk-version: r28c + + - name: Validate the Android core library + working-directory: core + shell: bash + env: + ANDROID_NDK_HOME: ${{ steps.core-ndk.outputs.ndk-path }} + run: | + # The step above pins CGO_ENABLED=0, which excludes every `android && + # cgo` file: lib.go, bride.go, tun/ and platform/limit.go. The job + # that builds them needs the NDK and only runs on a `v*` tag, so + # without this the entire Android surface of the core first compiles + # at release time. + CC="$(echo "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/aarch64-linux-android21-clang)" + test -x "$CC" || { echo "NDK clang not found at $CC" >&2; exit 1; } + # Same tags the shipped library is built with; read from the build + # tool's config so the two cannot drift. + tags="$(sed -n 's/^tags: *//p' ../plugins/setup/buildkit/build_tool/build_config.yaml)" + test -n "$tags" || { echo 'Could not read tags from build_config.yaml' >&2; exit 1; } + export CGO_ENABLED=1 GOOS=android GOARCH=arm64 CC + go vet "-tags=$tags" . ./tun ./platform + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Run Android unit tests + working-directory: android + shell: bash + run: | + # settings.gradle.kts resolves the Flutter tooling through local.properties, + # which only the Flutter build itself would otherwise generate. + { + echo "flutter.sdk=$FLUTTER_ROOT" + echo "sdk.dir=$ANDROID_HOME" + } > local.properties + # Modules are listed explicitly: the aggregate testDebugUnitTest task would + # also run third-party Flutter plugin suites. `:core` is omitted because its + # only class loads the native library through System.loadLibrary. + ./gradlew :common:testDebugUnitTest :service:testDebugUnitTest :app:testDebugUnitTest - name: Validate Rust components run: | @@ -144,6 +205,7 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive + fetch-depth: 0 - name: Setup Android Signing if: startsWith(matrix.platform,'android') @@ -203,61 +265,6 @@ jobs: path: ./dist overwrite: true - changelog: - if: ${{ !contains(github.ref, '-') }} - permissions: - contents: write - runs-on: ubuntu-latest - needs: [ build ] - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: refs/heads/main - - name: Generate - run: | - last_ver=$(grep -m1 '^## ' CHANGELOG.md 2>/dev/null | sed 's/^## //') - - mapfile -t tags < <(git tag --merged HEAD --sort=-creatordate) - - temp="NEW_CHANGELOG.md" - : > "$temp" - - for i in "${!tags[@]}"; do - curr="${tags[i]}" - [[ "$curr" == "$last_ver" ]] && break - - prev="${tags[i+1]}" - range="${prev:+$prev..}$curr" - - echo -e "## $curr\n" >> "$temp" - git log --no-merges --pretty=format:"%B" "$range" | \ - awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$temp" - done - [ -f CHANGELOG.md ] && cat CHANGELOG.md >> "$temp" - - mv "$temp" CHANGELOG.md - - - name: Commit - run: | - git add CHANGELOG.md - if ! git diff --cached --quiet; then - echo "Commit pushing" - git config --local user.email "chen08209@gmail.com" - git config --local user.name "chen08209" - git commit -m "Update changelog" - if git push; then - echo "Push succeeded" - else - echo "Push failed" - exit 1 - fi - fi - - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - upload: permissions: contents: write @@ -284,14 +291,31 @@ jobs: pattern: artifact-* merge-multiple: true - - name: Generate release.md + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Generate release notes shell: bash + env: + IS_STABLE: ${{ env.IS_STABLE }} + TAG: ${{ github.ref_name }} run: | - pre_tag=$( - curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" | - sed -nE 's/.*"tag_name": "([^"]+)".*/\1/p' - ) - bash .github/scripts/generate_release_notes.sh "$pre_tag" release.md + target=() + if [[ "$IS_STABLE" == 'true' ]]; then + target=(--tag "$TAG") + else + dart run tool/changelog.dart build --unreleased + fi + dart run tool/changelog.dart render release "${target[@]}" --out release.md + dart run tool/changelog.dart render telegram "${target[@]}" --out telegram.md + - name: Push to telegram env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} diff --git a/.gitignore b/.gitignore index c3bd0c480c..d4410d3f45 100644 --- a/.gitignore +++ b/.gitignore @@ -68,7 +68,6 @@ docs/ /macos/build/ /env.json /core_sha256.json -/core/*.exe /plugins/rust_api/.claude/ devtools_options.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bc1bb0a2b3..b34ed67dd4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,9 +6,15 @@ repos: entry: dart format language: system files: \.dart$ + stages: [pre-commit] - id: flutter-analyze name: flutter analyze (pre-push) entry: flutter analyze --no-fatal-infos language: system pass_filenames: false stages: [pre-push] + - id: commit-msg-format + name: conventional commit message + entry: tool/check_commit_msg.sh + language: system + stages: [commit-msg] diff --git a/AGENTS.md b/AGENTS.md index f5fc8089cb..6734c5e16c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ Read these only when the task touches their area: - Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded requests. +- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to. + The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules. - Follow `analysis_options.yaml`, especially single quotes, trailing commas, `child:` last, no `print()`, const/final preferences, and declared return types. - For CI parity, verify with `flutter pub get`, `flutter analyze --no-fatal-infos`, and diff --git a/CHANGELOG.md b/CHANGELOG.md index 0672215d74..5638a4d979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1028 +1,721 @@ -## v0.8.96 +# Changelog -- Optimize commented policy +## v0.8.96 (2026-08-17) -- Fix whole group delay test failing on Windows +**Bug Fixes** -- Optimize package icon loading and connections polling +- Fix whole group delay test failing on Windows (7fb4f4f) -## v0.8.95 +**Performance** -- Optimize core service +- Optimize package icon loading and connections polling (903e2b8) -- Optimize Android TV launcher icon + + -- Optimize back navigation +## v0.8.95 (2026-08-14) +- Optimize core service +- Optimize Android TV launcher icon +- Optimize back navigation - Optimize more details - - Fix some issues - - Optimize app layout - - Optimize focus control - - Adjust android process -## v0.8.94 +## v0.8.94 (2026-07-11) - Fix macos performance issue - - Support custom global-ua - - Update core - - Optimize some details - - Fix linux silent launching not working -## v0.8.93 +## v0.8.93 (2026-05-29) - Support custom overwrite - - Support run on demand - - Optimize windows ipc - - Optimize windows arm64 - - Optimize build - - Optimize some details - - Update core -## v0.8.92 +## v0.8.92 (2026-02-02) - Add sqlite store - - Optimize android quick action - - Optimize backup and restore - - Optimize more details -## v0.8.91 +## v0.8.91 (2025-12-12) - Fix windows some issues - - Optimize overwrite handle - - Optimize access control page - - Optimize some details -## v0.8.90 +## v0.8.90 (2025-10-08) - Fix android tile service - - Support append system DNS - - Fix some issues - - Update changelog -## v0.8.89 +## v0.8.89 (2025-09-27) - Fix some issues - - Optimize Windows service mode - - Update core - - Update changelog -## v0.8.88 +## v0.8.88 (2025-09-23) - Add android separates the core process - - Support core status check and force restart - - Optimize proxies page and access page - - Update flutter and pub dependencies - - Update go version - - Optimize more details - - Update changelog -## v0.8.87 +## v0.8.87 (2025-07-29) - Optimize desktop view - - Optimize logs, requests, connection pages - - Optimize windows tray auto hide - - Optimize some details - - Update core - - Update changelog -## v0.8.86 +## v0.8.86 (2025-06-15) - Fix windows tun issues - - Optimize android get system dns - - Optimize more details - - Update changelog -## v0.8.85 +## v0.8.85 (2025-06-07) - Support override script - - Support proxies search - - Support svg display - - Optimize config persistence - - Add some scenes auto close connections - - Update core - - Optimize more details -## v0.8.84 +## v0.8.84 (2025-05-01) - Fix windows service verify issues - - Update changelog -## v0.8.83 +## v0.8.83 (2025-05-01) - Add windows server mode start process verify - - Add linux deb dependencies - - Add backup recovery strategy select - - Support custom text scaling - - Optimize the display of different text scale - - Optimize windows setup experience - - Optimize startTun performance - - Optimize android tv experience - - Optimize default option - - Optimize computed text size - - Optimize hyperOS freeform window - - Add developer mode - - Update core - - Optimize more details - - Add issues template - - Update changelog -## v0.8.82 +## v0.8.82 (2025-04-18) - Optimize android vpn performance - - Add custom primary color and color scheme - - Add linux nad windows arm release - - Optimize requests and logs page - - Fix map input page delete issues - - Update changelog -## v0.8.81 +## v0.8.81 (2025-04-08) - Add rule override - - Update core - - Optimize more details - - Update changelog -## v0.8.80 +## v0.8.80 (2025-03-10) - Optimize dashboard performance - - Fix some issues - - Fix unselected proxy group delay issues - - Fix asn url issues - - Update changelog -## v0.8.79 +## v0.8.79 (2025-03-07) - Fix tab delay view issues - - Fix tray action issues - - Fix get profile redirect client ua issues - - Fix proxy card delay view issues - - Add Russian, Japanese adaptation - - Fix some issues - - Update changelog -## v0.8.78 +## v0.8.78 (2025-03-05) - Fix list form input view issues - - Fix traffic view issues - - Update changelog -## v0.8.77 +## v0.8.77 (2025-03-05) - Optimize performance - - Update core - - Optimize core stability - - Fix linux tun authority check error - - Fix some issues - - Fix scroll physics error - - Update changelog -## v0.8.75 +## v0.8.75 (2025-02-09) - Add windows storage corruption detection - - Fix core crash caused by windows resource manager restart - - Optimize logs, requests, access to pages - - Fix macos bypass domain issues - - Update changelog -## v0.8.74 +## v0.8.74 (2025-02-03) - Fix some issues - - Update changelog -## v0.8.73 +## v0.8.73 (2025-02-02) - Update popup menu - - Add file editor - - Fix android service issues - - Optimize desktop background performance - - Optimize android main process performance - - Optimize delay test - - Optimize vpn protect - - Update changelog -## v0.8.72 +## v0.8.72 (2025-01-10) - Update core - - Fix some issues - - Update changelog -## v0.8.71 +## v0.8.71 (2025-01-09) - Remake dashboard - - Optimize theme - - Optimize more details - - Update flutter version - - Update changelog -## v0.8.70 +## v0.8.70 (2024-12-09) - Support better window position memory - - Add windows arm64 and linux arm64 build script - - Optimize some details -## v0.8.69 +## v0.8.69 (2024-12-06) - Remake desktop - - Optimize change proxy - - Optimize network check - - Fix fallback issues - - Optimize lots of details - - Update change.yaml - - Fix android tile issues - - Fix windows tray issues - - Support setting bypassDomain - - Update flutter version - - Fix android service issues - - Fix macos dock exit button issues - - Add route address setting - - Optimize provider view - - Update changelog - - Update CHANGELOG.md -## v0.8.67 +## v0.8.67 (2024-11-09) - Add android shortcuts - - Fix init params issues - - Fix dynamic color issues - - Optimize navigator animate - - Optimize window init - - Optimize fab - - Optimize save -## v0.8.66 +## v0.8.66 (2024-10-26) - Fix the collapse issues - - Add fontFamily options -## v0.8.65 +## v0.8.65 (2024-10-26) - Update core version - - Update flutter version - - Optimize ip check - - Optimize url-test -## v0.8.64 +## v0.8.64 (2024-10-12) - Update release message - - Init auto gen changelog - - Fix windows tray issues - - Fix urltest issues - - Add auto changelog - - Fix windows admin auto launch issues - - Add android vpn options - - Support proxies icon configuration - - Optimize android immersion display - - Fix some issues - - Optimize ip detection - - Support android vpn ipv6 inbound switch - - Support log export - - Optimize more details - - Fix android system dns issues - - Optimize dns default option - - Fix some issues - - Update readme -## v0.8.60 +## v0.8.60 (2024-09-17) - Fix build error2 - - Fix build error - - Support desktop hotkey - - Support android ipv6 inbound - - Support android system dns - - fix some bugs -## v0.8.59 +## v0.8.59 (2024-09-09) - Fix delete profile error -## v0.8.58 +## v0.8.58 (2024-09-08) - Fix submit error 2 - - Fix submit error - - Optimize DNS strategy - - Fix the problem that the tray is not displayed in some cases - - Optimize tray - - Update core - - Fix some error -## v0.8.57 +## v0.8.57 (2024-09-02) - Fix tun update issues - - Add DNS override - Fixed some bugs - Optimize more detail - - Add Hosts override -## v0.8.56 +## v0.8.56 (2024-08-26) - fix android tip error - fix windows auto launch error -## v0.8.55 +## v0.8.55 (2024-08-25) - Fix windows tray issues - - Optimize windows logic - - Optimize app logic - - Support windows administrator auto launch - - Support android close vpn -## v0.8.53 +## v0.8.53 (2024-08-15) - Change flutter version - - Support profiles sort - - Support windows country flags display - - Optimize proxies page and profiles page columns -## v0.8.52 +## v0.8.52 (2024-08-11) - Update flutter version - - Update version - - Update timeout time - - Update access control page - - Fix bug -## v0.8.51 +## v0.8.51 (2024-08-05) - Optimize provider page - - Optimize delay test - - Support local backup and recovery - - Fix android tile service issues -## v0.8.49 +## v0.8.49 (2024-07-31) - Fix linux core build error - - Add proxy-only traffic statistics - - Update core - - Optimize more details - - Merge pull request #140 from txyyh/main - - 添加自建 F-Droid 仓库相关 workflow - Rename readme fingerprint - - Rename workflow deploy repo name - - Add download guide to README - - Add push release files to fdroid-repo -## v0.8.48 +## v0.8.48 (2024-07-25) - Optimize proxies page - - Fix ua issues - - Optimize more details -## v0.8.47 +## v0.8.47 (2024-07-22) - Fix windows build error -## v0.8.46 +## v0.8.46 (2024-07-22) - Update app icon - - Fix desktop backup error - - Optimize request ua - - Change android icon - - Optimize dashboard -## v0.8.44 +## v0.8.44 (2024-07-18) - Remove request validate certificate - - Sync core -## v0.8.43 +## v0.8.43 (2024-07-18) - Fix windows error -## v0.8.42 +## v0.8.42 (2024-07-18) - Fix setup.dart error - - Fix android system proxy not effective - - Add macos arm64 -## v0.8.41 +## v0.8.41 (2024-07-17) - Optimize proxies page - - Support mouse drag scroll - - Adjust desktop ui - - Revert "Fix android vpn issues" - - This reverts commit 891977408e6938e2acd74e9b9adb959c48c79988. -## v0.8.40 +## v0.8.40 (2024-07-15) - Fix android vpn issues - - Fix android vpn issues - - Rollback partial modification -## v0.8.39 +## v0.8.39 (2024-07-15) - Fix the problem that ui can't be synchronized when android vpn is occupied by an external - - Override default socksPort,port -## v0.8.38 +## v0.8.38 (2024-07-14) - Fix fab issues -## v0.8.37 +## v0.8.37 (2024-07-14) - Update version - - Fix the problem that vpn cannot be started in some cases - - Fix the problem that geodata url does not take effect -## v0.8.36 +## v0.8.36 (2024-07-13) - Update ua - - Fix change outbound mode without check ip issues - - Separate android ui and vpn - - Fix url validate issues 2 - - Add android hidden from the recent task - - Add geoip file - - Support modify geoData URL -## v0.8.35 +## v0.8.35 (2024-07-07) - Fix url validate issues - - Fix check ip performance problem - - Optimize resources page -## v0.8.34 +## v0.8.34 (2024-07-04) - Add ua selector - - Support modify test url - - Optimize android proxy - - Fix the error that async proxy provider could not selected the proxy -## v0.8.33 +## v0.8.33 (2024-07-01) - Fix android proxy error - - Fix submit error - - Add windows tun - - Optimize android proxy - - Optimize change profile - - Update application ua - - Optimize delay test -## v0.8.32 +## v0.8.32 (2024-06-28) - Fix android repeated request notification issues -## v0.8.31 +## v0.8.31 (2024-06-28) - Fix memory overflow issues -## v0.8.30 +## v0.8.30 (2024-06-27) - Optimize proxies expansion panel 2 - - Fix android scan qrcode error -## v0.8.29 +## v0.8.29 (2024-06-27) - Optimize proxies expansion panel - - Fix text error -## v0.8.28 +## v0.8.28 (2024-06-26) - Optimize proxy - - Optimize delayed sorting performance - - Add expansion panel proxies page - - Support to adjust the proxy card size - - Support to adjust proxies columns number - - Fix autoRun show issues - - Fix Android 10 issues - - Optimize ip show -## v0.8.26 +## v0.8.26 (2024-06-22) - Add intranet IP display - - Add connections page - - Add search in connections, requests - - Add keyword search in connections, requests, logs - - Add basic viewing editing capabilities - - Optimize update profile -## v0.8.25 +## v0.8.25 (2024-06-19) - Update version - - Fix the problem of excessive memory usage in traffic usage. - - Add lightBlue theme color - - Fix start unable to update profile issues - - Fix flashback caused by process -## v0.8.23 +## v0.8.23 (2024-06-16) - Add build version - - Optimize quick start - - Update system default option -## v0.8.22 +## v0.8.22 (2024-06-16) - Update build.yml - - Fix android vpn close issues - - Add requests page - - Fix checkUpdate dark mode style error - - Fix quickStart error open app - - Add memory proxies tab index - - Support hidden group - - Optimize logs - - Fix externalController hot load error -## v0.8.21 +## v0.8.21 (2024-06-13) - Add tcp concurrent switch - - Add system proxy switch - - Add geodata loader switch - - Add external controller switch - - Add auto gc on trim memory - - Fix android notification error -## v0.8.20 +## v0.8.20 (2024-06-12) - Fix ipv6 error - - Fix android udp direct error - - Add ipv6 switch - - Add access all selected button - - Remove android low version splash -## v0.8.19 +## v0.8.19 (2024-06-10) - Update version - - Add allowBypass - - Fix Android only pick .text file issues -## v0.8.18 +## v0.8.18 (2024-06-09) - Fix search issues -## v0.8.17 +## v0.8.17 (2024-06-09) - Fix LoadBalance, Relay load error - - Fix build.yml4 - - Fix build.yml3 - - Fix build.yml2 - - Fix build.yml - - Add search function at access control - - Fix the issues with the profile add button to cover the edit button - - Adapt LoadBalance and Relay - - Add arm - - Fix android notification icon error -## v0.8.16 +## v0.8.16 (2024-06-08) - Add one-click update all profiles - Add expire show -## v0.8.15 +## v0.8.15 (2024-06-06) - Temp remove tun mode - - Remove macos in workflow - - Change go version -## v0.8.14 +## v0.8.14 (2024-06-06) - Update Version - - Fix tun unable to open -## v0.8.13 +## v0.8.13 (2024-06-06) - Optimize delay test2 - - Optimize delay test - - Add check ip - - add check ip request -## v0.8.12 +## v0.8.12 (2024-06-06) - Fix the problem that the download of remote resources failed after GeodataMode was turned on, which caused the application to flash back. - - Fix edit profile error - - Fix quickStart change proxy error - - Fix core version -## v0.8.10 +## v0.8.10 (2024-06-05) - Fix core version -## v0.8.9 +## v0.8.9 (2024-06-05) - Update file_picker - - Add resources page - - Optimize more detail - - Add access selected sorted - - Fix notification duplicate creation issue - - Fix AccessControl click issue -## v0.8.7 +## v0.8.7 (2024-05-31) - Fix Workflow - - Fix Linux unable to open - - Update README.md 3 - - Create LICENSE - Update README.md 2 - - Update README.md - - Optimize workFlow -## v0.8.6 +## v0.8.6 (2024-05-31) - optimize checkUpdate -## v0.8.5 +## v0.8.5 (2024-05-30) - Fix submit error -## v0.8.4 +## v0.8.4 (2024-05-30) - add WebDAV - - add Auto check updates - - Optimize more details - - optimize delayTest -## v0.8.2 +## v0.8.2 (2024-05-15) - upgrade flutter version -## v0.8.1 +## v0.8.1 (2024-05-15) - Update kernel - Add import profile via QR code image -## v0.8.0 +## v0.8.0 (2024-05-11) - Add compatibility mode and adapt clash scheme. -## v0.7.14 +## v0.7.14 (2024-05-07) - update Version - - Reconstruction application proxy logic -## v0.7.13 +## v0.7.13 (2024-05-06) - Fix Tab destroy error -## v0.7.12 +## v0.7.12 (2024-05-06) - Optimize repeat healthcheck -## v0.7.11 +## v0.7.11 (2024-05-06) - Optimize Direct mode ui -## v0.7.10 +## v0.7.10 (2024-05-06) - Optimize Healthcheck - - Remove proxies position animation, improve performance - Add Telegram Link - - Update healthcheck policy - - New Check URLTest - - Fix the problem of invalid auto-selection -## v0.7.8 +## v0.7.8 (2024-05-05) - New Async UpdateConfig - - add changeProfileDebounce - - Update Workflow - - Fix ChangeProfile block - - Fix Release Message Error -## v0.7.7 +## v0.7.7 (2024-05-04) - Update Selector 2 -## v0.7.6 +## v0.7.6 (2024-05-04) - Update Version - - Fix Proxies Select Error -## v0.7.5 +## v0.7.5 (2024-05-03) - Fix the problem that the proxy group is empty in global mode. - - Fix the problem that the proxy group is empty in global mode. -## v0.7.4 +## v0.7.4 (2024-05-03) - Add ProxyProvider2 -## v0.7.3 +## v0.7.3 (2024-05-03) - Add ProxyProvider - - Update Version - - Update ProxyGroup Sort - - Fix Android quickStart VpnService some problems -## v0.7.1 +## v0.7.1 (2024-05-01) - Update version - - Set Android notification low importance - - Fix the issue that VpnService can't be closed correctly in special cases - - Fix the problem that TileService is not destroyed correctly in some cases - - Adjust tab animation defaults - - Add Telegram in README_zh_CN.md - - Add Telegram -## v0.7.0 +## v0.7.0 (2024-04-30) - update mobile_scanner - -- Initial commit \ No newline at end of file +- Initial commit diff --git a/analysis_options.yaml b/analysis_options.yaml index bb1a3a6772..fece1df28d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,5 @@ -include: package:flutter_lints/flutter.yaml +include: lint_options.yaml + analyzer: exclude: - build/** @@ -7,37 +8,3 @@ analyzer: - plugins/** errors: invalid_annotation_target: ignore - -linter: - rules: - # Style - prefer_single_quotes: true - require_trailing_commas: true - sort_child_properties_last: true - - # Avoid unnecessary code - unnecessary_new: true - unnecessary_this: true - unnecessary_const: true - avoid_print: true - avoid_unnecessary_containers: true - sized_box_for_whitespace: true - - # Prefer best practices - prefer_const_constructors: true - prefer_const_declarations: true - prefer_final_locals: true - prefer_final_in_for_each: true - prefer_is_empty: true - prefer_is_not_empty: true - prefer_interpolation_to_compose_strings: true - - # Type safety - always_declare_return_types: true - annotate_overrides: true - use_key_in_widget_constructors: true - no_leading_underscores_for_local_identifiers: true - - # Async - use_build_context_synchronously: true - unnecessary_await_in_return: true \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5d37e8851f..40799875eb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -81,6 +81,11 @@ android { ) } } + + sourceSets { + // Unit tests live under android/tests/ instead of each module's src/test. + getByName("test").java.setSrcDirs(listOf("../tests/app")) + } } kotlin { @@ -105,4 +110,6 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.crashlytics.ndk) implementation(libs.firebase.analytics) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/android/app/src/main/kotlin/com/follow/clash/ServiceBroadcastReceiver.kt b/android/app/src/main/kotlin/com/follow/clash/ServiceBroadcastReceiver.kt index b92b808da7..78a915ed2c 100644 --- a/android/app/src/main/kotlin/com/follow/clash/ServiceBroadcastReceiver.kt +++ b/android/app/src/main/kotlin/com/follow/clash/ServiceBroadcastReceiver.kt @@ -6,21 +6,18 @@ import android.content.Intent import android.os.Handler import android.os.Looper import com.follow.clash.common.BroadcastAction +import com.follow.clash.common.BroadcastLease import com.follow.clash.common.GlobalState import com.follow.clash.common.action -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.launch class ServiceBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { val action = intent?.action ?: return val pendingResult = goAsync() - val finished = AtomicBoolean(false) + val lease = BroadcastLease { pendingResult.finish() } val timeout = Runnable { - if (finished.compareAndSet(false, true)) { - GlobalState.log("Broadcast handling timed out: $action") - pendingResult.finish() - } + lease.release { GlobalState.log("Broadcast handling timed out: $action") } } mainHandler.postDelayed(timeout, BROADCAST_TIMEOUT_MILLIS) GlobalState.launch { @@ -30,9 +27,7 @@ class ServiceBroadcastReceiver : BroadcastReceiver() { GlobalState.log("Unable to handle service broadcast $action: $error") } finally { mainHandler.removeCallbacks(timeout) - if (finished.compareAndSet(false, true)) { - pendingResult.finish() - } + lease.release() } } } diff --git a/android/app/src/main/kotlin/com/follow/clash/ServiceState.kt b/android/app/src/main/kotlin/com/follow/clash/ServiceState.kt index b198faa28b..b4d49fbcee 100644 --- a/android/app/src/main/kotlin/com/follow/clash/ServiceState.kt +++ b/android/app/src/main/kotlin/com/follow/clash/ServiceState.kt @@ -1,323 +1,37 @@ package com.follow.clash -import android.net.VpnService -import com.follow.clash.common.GlobalState import com.follow.clash.models.SharedState -import com.follow.clash.plugins.AppPlugin -import com.follow.clash.plugins.TilePlugin -import com.follow.clash.service.ServiceConfig -import com.follow.clash.service.models.NotificationParams -import com.follow.clash.service.models.VpnOptions -import com.google.gson.Gson import io.flutter.embedding.engine.FlutterEngine -import java.util.concurrent.atomic.AtomicReference -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.coroutines.resume - -enum class RunState { - STARTED, - STARTING, - STOPPING, - STOPPED, -} - -private const val MISSING_CONFIG_MESSAGE = "No configuration found." -private const val INVALID_CONFIG_MESSAGE = "Invalid configuration." -private const val VPN_PERMISSION_MESSAGE = "VPN permission required." -private const val START_FAILED_MESSAGE = "Failed to start service." object ServiceState { - private val transitionLock = Mutex() - private val startPreparationLock = Mutex() - private val mutableRunState = MutableStateFlow(RunState.STOPPED) - private val latestRequest = AtomicReference(RunRequest(running = false)) - - @Volatile - private var sharedState = SharedState() - - @Volatile - private var flutterEngine: FlutterEngine? = null - - val runState = mutableRunState.asStateFlow() - - private val runTimeMillis: Long - get() = ServiceController.getRunTimeMillis() - - private val appPlugin: AppPlugin? - get() = flutterEngine?.plugin() - - private val tilePlugin: TilePlugin? - get() = flutterEngine?.plugin() - - fun attachFlutterEngine(engine: FlutterEngine) { - flutterEngine = engine - } - - fun detachFlutterEngine(engine: FlutterEngine) { - if (flutterEngine === engine) { - flutterEngine = null - } - } - - suspend fun handleToggleAction() { - if (isRunningRequested()) { - handleStopAction() - } else { - handleStartAction() - } - } - - suspend fun refresh(): Long = transitionLock.withLock { - val current = runTimeMillis - mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED - current - } - - internal fun captureRequestToken(): RunRequest = latestRequest.get() - - /** - * Settles the state after the bound service was lost. [token] is the request that was current - * when the loss was observed, so a start that raced ahead of this callback keeps its intent. - */ - internal suspend fun handleServiceLost(token: RunRequest) = transitionLock.withLock { - if (runTimeMillis != 0L) { - return@withLock - } - if (!latestRequest.compareAndSet(token, RunRequest(running = false))) { - return@withLock - } - mutableRunState.value = RunState.STOPPED - } - - suspend fun handleStartAction() { - if (isRunningRequested()) { - return - } - val plugin = tilePlugin - if (plugin != null) { - plugin.handleStart() - return - } - loadPreferencesAndStart() - } - - suspend fun handleStopAction() { - if (!isRunningRequested()) { - return - } - val plugin = tilePlugin - if (plugin != null) { - plugin.handleStop() - return - } - GlobalState.application.showToast(sharedState.stopTip) - requestStop().await() - } - - suspend fun handleVpnRevokeAction() { - if (!ServiceController.isVpnServiceActive()) { - return - } - handleStopAction() - } - - fun requestStart(): Deferred { - val request = createRequest(running = true) - val result = CompletableDeferred() - val launchRequest: (Boolean) -> Unit = { shouldStart -> - if (!shouldStart) { - fail(request) - result.complete(false) - } else { - GlobalState.launch { - result.complete( - runCatching { start(request) } - .onFailure { error -> - GlobalState.log("Unable to process service start request: $error") - fail(request) - } - .getOrDefault(false), - ) - } - } - } - val plugin = appPlugin - if (plugin != null) { - plugin.requestNotificationPermission(launchRequest) - } else { - launchRequest(true) - } - return result - } - - fun requestStop(): Deferred { - val request = createRequest(running = false) - val result = CompletableDeferred() - GlobalState.launch { - result.complete( - runCatching { stop(request) } - .onFailure { error -> - GlobalState.log("Unable to process service stop request: $error") - } - .getOrDefault(false), - ) - } - return result - } - - fun syncSharedState(state: SharedState) { - sharedState = state - applySharedState() - } + private val machine = ServiceStateMachine(AndroidServiceStateHost) - private suspend fun loadPreferencesAndStart() { - sharedState = GlobalState.application.sharedState - if (sharedState.setupParams == null || sharedState.vpnOptions == null) { - GlobalState.application.showToast(MISSING_CONFIG_MESSAGE) - return - } - if (setupCore()) { - if (!requestStart().await()) { - GlobalState.application.showToast(START_FAILED_MESSAGE) - } - } - } + val runState = machine.runState - private fun applySharedState() { - GlobalState.setCrashlytics(sharedState.crashlytics) - ServiceConfig.updateNotificationParams( - NotificationParams( - title = sharedState.currentProfileName, - stopText = sharedState.stopText, - onlyStatisticsProxy = sharedState.onlyStatisticsProxy, - ), - ) - } + fun attachFlutterEngine(engine: FlutterEngine) = + AndroidServiceStateHost.attachFlutterEngine(engine) - private suspend fun setupCore(): Boolean { - applySharedState() - GlobalState.application.showToast(sharedState.startTip) - val initParams = Gson().toJson( - mapOf( - "home-dir" to GlobalState.application.filesDir.path, - "version" to android.os.Build.VERSION.SDK_INT, - ), - ) - val setupParams = Gson().toJson(sharedState.setupParams) - return ServiceController.quickSetup( - initParams, - setupParams, - ).fold( - onSuccess = { message -> - if (message.isEmpty()) { - true - } else { - GlobalState.log("Unable to set up core: $message") - showConfigError(message) - false - } - }, - onFailure = { error -> - GlobalState.log("Unable to set up core: $error") - showConfigError(error.message) - false - }, - ) - } + fun detachFlutterEngine(engine: FlutterEngine) = + AndroidServiceStateHost.detachFlutterEngine(engine) - private fun showConfigError(message: String?) { - GlobalState.application.showToast( - message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE, - ) - } + suspend fun handleToggleAction() = machine.handleToggleAction() - private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock { - if (!isCurrent(request)) { - return@withLock false - } - val options = sharedState.vpnOptions - if (options == null) { - fail(request) - return@withLock false - } - if (!prepareVpn(options)) { - if (appPlugin == null && isCurrent(request)) { - GlobalState.application.showToast(VPN_PERMISSION_MESSAGE) - } - fail(request) - return@withLock false - } - if (!isCurrent(request)) { - return@withLock false - } + suspend fun handleStartAction() = machine.handleStartAction() - transitionLock.withLock transition@{ - if (!isCurrent(request)) { - return@transition false - } - if (runState.value == RunState.STARTED && runTimeMillis != 0L) { - return@transition true - } - mutableRunState.value = RunState.STARTING - val startedAtMillis = ServiceController.start(options) - mutableRunState.value = - if (startedAtMillis == 0L) RunState.STOPPED else RunState.STARTED - if (startedAtMillis == 0L) { - fail(request) - return@transition false - } - isCurrent(request) - } - } + suspend fun handleStopAction() = machine.handleStopAction() - private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock { - if (!isCurrent(request)) { - return@withLock false - } - if (runState.value == RunState.STOPPED && runTimeMillis == 0L) { - return@withLock true - } - mutableRunState.value = RunState.STOPPING - ServiceController.stop() - mutableRunState.value = RunState.STOPPED - isCurrent(request) - } + suspend fun handleVpnRevokeAction() = machine.handleVpnRevokeAction() - private suspend fun prepareVpn(options: VpnOptions): Boolean { - val plugin = appPlugin - ?: return !options.enable || VpnService.prepare(GlobalState.application) == null - return suspendCancellableCoroutine { continuation -> - val callback: (Boolean) -> Unit = { granted -> - if (continuation.isActive) { - continuation.resume(granted) - } - } - continuation.invokeOnCancellation { - plugin.cancelVpnPreparation(callback) - } - plugin.prepareVpn(options.enable, callback) - } - } + suspend fun refresh(): Long = machine.refresh() - private fun createRequest(running: Boolean): RunRequest = - RunRequest(running).also(latestRequest::set) + fun requestStart(): Deferred = machine.requestStart() - private fun isRunningRequested(): Boolean = latestRequest.get().running + fun requestStop(): Deferred = machine.requestStop() - private fun isCurrent(request: RunRequest): Boolean = latestRequest.get() === request + fun syncSharedState(state: SharedState) = machine.syncSharedState(state) - private fun fail(request: RunRequest) { - latestRequest.compareAndSet(request, RunRequest(running = false)) - } + internal fun captureRequestToken(): RunRequest = machine.captureRequestToken() - internal class RunRequest( - val running: Boolean, - ) + internal suspend fun handleServiceLost(token: RunRequest) = machine.handleServiceLost(token) } diff --git a/android/app/src/main/kotlin/com/follow/clash/ServiceStateHost.kt b/android/app/src/main/kotlin/com/follow/clash/ServiceStateHost.kt new file mode 100644 index 0000000000..154c5e6975 --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/ServiceStateHost.kt @@ -0,0 +1,137 @@ +package com.follow.clash + +import android.net.VpnService +import com.follow.clash.common.GlobalState +import com.follow.clash.models.SharedState +import com.follow.clash.plugins.AppPlugin +import com.follow.clash.plugins.TilePlugin +import com.follow.clash.service.ServiceConfig +import com.follow.clash.service.models.NotificationParams +import com.follow.clash.service.models.VpnOptions +import io.flutter.embedding.engine.FlutterEngine +import kotlinx.coroutines.CoroutineScope + +/** + * Everything [ServiceStateMachine] needs from the Android runtime. + * + * The machine owns the arbitration and the transitions; this is only the part that cannot run on a + * plain JVM, so unit tests can drive the state machine with an in-memory implementation. + */ +internal interface ServiceStateHost { + val scope: CoroutineScope + val runTimeMillis: Long + val homeDirPath: String + val sdkInt: Int + + fun log(message: String) + + fun showToast(message: String) + + fun setCrashlytics(enabled: Boolean) + + fun updateNotificationParams(params: NotificationParams) + + fun loadSharedState(): SharedState + + fun isVpnPermissionGranted(): Boolean + + fun tile(): TileGateway? + + fun app(): AppGateway? + + suspend fun quickSetup(initParams: String, setupParams: String): Result + + suspend fun startService(options: VpnOptions): Long + + suspend fun stopService() + + suspend fun isVpnServiceActive(): Boolean +} + +/** The Quick Settings tile surface, backed by [TilePlugin] in production. */ +internal interface TileGateway { + fun handleStart() + + fun handleStop() +} + +/** The foreground-app surface, backed by [AppPlugin] in production. */ +internal interface AppGateway { + fun requestNotificationPermission(callback: (Boolean) -> Unit) + + fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) + + fun cancelVpnPreparation(callback: (Boolean) -> Unit) +} + +internal object AndroidServiceStateHost : ServiceStateHost { + @Volatile + private var flutterEngine: FlutterEngine? = null + + override val scope: CoroutineScope + get() = GlobalState + + override val runTimeMillis: Long + get() = ServiceController.getRunTimeMillis() + + override val homeDirPath: String + get() = GlobalState.application.filesDir.path + + override val sdkInt: Int + get() = android.os.Build.VERSION.SDK_INT + + fun attachFlutterEngine(engine: FlutterEngine) { + flutterEngine = engine + } + + fun detachFlutterEngine(engine: FlutterEngine) { + if (flutterEngine === engine) { + flutterEngine = null + } + } + + override fun log(message: String) = GlobalState.log(message) + + override fun showToast(message: String) = GlobalState.application.showToast(message) + + override fun setCrashlytics(enabled: Boolean) = GlobalState.setCrashlytics(enabled) + + override fun updateNotificationParams(params: NotificationParams) = + ServiceConfig.updateNotificationParams(params) + + override fun loadSharedState(): SharedState = GlobalState.application.sharedState + + override fun isVpnPermissionGranted(): Boolean = + VpnService.prepare(GlobalState.application) == null + + override fun tile(): TileGateway? = flutterEngine?.plugin()?.let { plugin -> + object : TileGateway { + override fun handleStart() = plugin.handleStart() + + override fun handleStop() = plugin.handleStop() + } + } + + override fun app(): AppGateway? = flutterEngine?.plugin()?.let { plugin -> + object : AppGateway { + override fun requestNotificationPermission(callback: (Boolean) -> Unit) = + plugin.requestNotificationPermission(callback) + + override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) = + plugin.prepareVpn(enable, callback) + + override fun cancelVpnPreparation(callback: (Boolean) -> Unit) = + plugin.cancelVpnPreparation(callback) + } + } + + override suspend fun quickSetup(initParams: String, setupParams: String): Result = + ServiceController.quickSetup(initParams, setupParams) + + override suspend fun startService(options: VpnOptions): Long = + ServiceController.start(options) + + override suspend fun stopService() = ServiceController.stop() + + override suspend fun isVpnServiceActive(): Boolean = ServiceController.isVpnServiceActive() +} diff --git a/android/app/src/main/kotlin/com/follow/clash/ServiceStateMachine.kt b/android/app/src/main/kotlin/com/follow/clash/ServiceStateMachine.kt new file mode 100644 index 0000000000..6b34d2c6ef --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/ServiceStateMachine.kt @@ -0,0 +1,343 @@ +package com.follow.clash + +import com.follow.clash.common.RunIntentArbiter +import com.follow.clash.models.SharedState +import com.follow.clash.service.models.NotificationParams +import com.follow.clash.service.models.VpnOptions +import com.google.gson.Gson +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.resume + +enum class RunState { + STARTED, + STARTING, + STOPPING, + STOPPED, +} + +internal typealias RunRequest = RunIntentArbiter.Token + +internal const val MISSING_CONFIG_MESSAGE = "No configuration found." +internal const val INVALID_CONFIG_MESSAGE = "Invalid configuration." +internal const val VPN_PERMISSION_MESSAGE = "VPN permission required." +internal const val START_FAILED_MESSAGE = "Failed to start service." + +/** + * Serializes run intents onto the bound background service. + * + * Callers request a transition; the newest request always wins. Every step that outlives its own + * suspension point re-checks [isCurrent] before it publishes anything, so a start that was overtaken + * by a stop cannot report itself as started. + */ +internal class ServiceStateMachine(private val host: ServiceStateHost) { + private val transitionLock = Mutex() + private val startPreparationLock = Mutex() + private val mutableRunState = MutableStateFlow(RunState.STOPPED) + private val arbiter = RunIntentArbiter() + + @Volatile + private var sharedState = SharedState() + + @Volatile + private var pendingVpnPreparation: (() -> Unit)? = null + + val runState = mutableRunState.asStateFlow() + + private val runTimeMillis: Long + get() = host.runTimeMillis + + suspend fun handleToggleAction() { + if (isRunningRequested()) { + handleStopAction() + } else { + handleStartAction() + } + } + + suspend fun refresh(): Long = transitionLock.withLock { + val current = runTimeMillis + mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED + current + } + + fun captureRequestToken(): RunRequest = arbiter.current() + + /** + * Settles the state after the bound service was lost. [token] is the request that was current + * when the loss was observed, so a start that raced ahead of this callback keeps its intent. + */ + suspend fun handleServiceLost(token: RunRequest) = transitionLock.withLock { + if (runTimeMillis != 0L) { + return@withLock + } + if (!arbiter.resetToStopped(token)) { + return@withLock + } + mutableRunState.value = RunState.STOPPED + } + + suspend fun handleStartAction() { + if (isRunningRequested()) { + return + } + val tile = host.tile() + if (tile != null) { + tile.handleStart() + return + } + loadPreferencesAndStart() + } + + suspend fun handleStopAction() { + if (!isRunningRequested()) { + return + } + val tile = host.tile() + if (tile != null) { + tile.handleStop() + return + } + host.showToast(sharedState.stopTip) + requestStop().await() + } + + suspend fun handleVpnRevokeAction() { + if (!host.isVpnServiceActive()) { + return + } + handleStopAction() + } + + fun requestStart(): Deferred { + val request = createRequest(running = true) + val result = CompletableDeferred() + val launchRequest: (Boolean) -> Unit = { shouldStart -> + if (!shouldStart) { + fail(request) + result.complete(false) + } else { + host.scope.launch { + result.complete( + runCatching { start(request) } + .onFailure { error -> + host.log("Unable to process service start request: $error") + fail(request) + reconcileStopped() + } + .getOrDefault(false), + ) + } + } + } + val app = host.app() + if (app != null) { + app.requestNotificationPermission(launchRequest) + } else { + launchRequest(true) + } + return result + } + + fun requestStop(): Deferred { + val request = createRequest(running = false) + val result = CompletableDeferred() + host.scope.launch { + result.complete( + runCatching { stop(request) } + .onFailure { error -> + host.log("Unable to process service stop request: $error") + } + .getOrDefault(false), + ) + } + return result + } + + fun syncSharedState(state: SharedState) { + sharedState = state + applySharedState() + } + + private suspend fun loadPreferencesAndStart() { + sharedState = host.loadSharedState() + if (sharedState.setupParams == null || sharedState.vpnOptions == null) { + host.showToast(MISSING_CONFIG_MESSAGE) + return + } + if (setupCore()) { + if (!requestStart().await()) { + host.showToast(START_FAILED_MESSAGE) + } + } + } + + private fun applySharedState() { + host.setCrashlytics(sharedState.crashlytics) + host.updateNotificationParams(notificationParams(sharedState)) + } + + private suspend fun setupCore(): Boolean { + applySharedState() + host.showToast(sharedState.startTip) + return host.quickSetup( + initParams(host.homeDirPath, host.sdkInt), + Gson().toJson(sharedState.setupParams), + ).fold( + onSuccess = { message -> + if (message.isEmpty()) { + true + } else { + host.log("Unable to set up core: $message") + showConfigError(message) + false + } + }, + onFailure = { error -> + host.log("Unable to set up core: $error") + showConfigError(error.message) + false + }, + ) + } + + private fun showConfigError(message: String?) { + host.showToast(message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE) + } + + private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock { + val started = runStart(request) + if (!started) { + reconcileStopped() + } + started + } + + private suspend fun runStart(request: RunRequest): Boolean { + if (!isCurrent(request)) { + return false + } + val options = sharedState.vpnOptions + if (options == null) { + fail(request) + return false + } + if (!prepareVpn(options)) { + if (host.app() == null && isCurrent(request)) { + host.showToast(VPN_PERMISSION_MESSAGE) + } + fail(request) + return false + } + if (!isCurrent(request)) { + return false + } + + return transitionLock.withLock transition@{ + if (!isCurrent(request)) { + return@transition false + } + if (runState.value == RunState.STARTED && runTimeMillis != 0L) { + return@transition true + } + mutableRunState.value = RunState.STARTING + val startedAtMillis = host.startService(options) + if (startedAtMillis == 0L) { + mutableRunState.value = RunState.STOPPED + fail(request) + return@transition false + } + if (!isCurrent(request)) { + return@transition false + } + mutableRunState.value = RunState.STARTED + true + } + } + + private suspend fun reconcileStopped() = transitionLock.withLock { + if (isRunningRequested() || runTimeMillis == 0L) { + return@withLock + } + mutableRunState.value = RunState.STOPPING + host.stopService() + mutableRunState.value = RunState.STOPPED + } + + private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock { + if (!isCurrent(request)) { + return@withLock false + } + abandonVpnPreparation() + if (runState.value == RunState.STOPPED && runTimeMillis == 0L) { + return@withLock true + } + mutableRunState.value = RunState.STOPPING + host.stopService() + mutableRunState.value = RunState.STOPPED + isCurrent(request) + } + + private suspend fun prepareVpn(options: VpnOptions): Boolean { + val app = host.app() + ?: return !options.enable || host.isVpnPermissionGranted() + return suspendCancellableCoroutine { continuation -> + val callback: (Boolean) -> Unit = { granted -> + pendingVpnPreparation = null + if (continuation.isActive) { + continuation.resume(granted) + } + } + pendingVpnPreparation = { + app.cancelVpnPreparation(callback) + callback(false) + } + continuation.invokeOnCancellation { + pendingVpnPreparation = null + app.cancelVpnPreparation(callback) + } + app.prepareVpn(options.enable, callback) + } + } + + private fun abandonVpnPreparation() { + val abandon = pendingVpnPreparation ?: return + pendingVpnPreparation = null + abandon() + } + + private fun createRequest(running: Boolean): RunRequest = arbiter.request(running) + + private fun isRunningRequested(): Boolean = arbiter.isRunningRequested + + private fun isCurrent(request: RunRequest): Boolean = arbiter.isCurrent(request) + + private fun fail(request: RunRequest) { + arbiter.resetToStopped(request) + } + + internal companion object { + /** + * The Core init payload. The key spelling is a cross-language contract with the Go wrapper, + * not an implementation detail. + */ + fun initParams(homeDirPath: String, sdkInt: Int): String = Gson().toJson( + mapOf( + "home-dir" to homeDirPath, + "version" to sdkInt, + ), + ) + + fun notificationParams(state: SharedState): NotificationParams = NotificationParams( + title = state.currentProfileName, + stopText = state.stopText, + onlyStatisticsProxy = state.onlyStatisticsProxy, + ) + } +} diff --git a/android/app/src/main/kotlin/com/follow/clash/packages/ChinaPackageMatcher.kt b/android/app/src/main/kotlin/com/follow/clash/packages/ChinaPackageMatcher.kt new file mode 100644 index 0000000000..ad2ea75539 --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/packages/ChinaPackageMatcher.kt @@ -0,0 +1,92 @@ +package com.follow.clash.packages + +/** + * Decides whether a package name or a fully qualified class name looks like it + * belongs to a domestic app or SDK. + * + * The prefixes in [CHINA_PACKAGE_REGEX] are matched *without* a trailing dot + * boundary, and that is deliberate: `com.qihoo` has to reach `com.qihoo360.*` + * and `com.ali` has to reach `com.aliyun.*` and `com.alimama.*`. Requiring a + * separator would turn those into misses. + * + * The price is that unrelated names starting with the same letters match too. + * [SKIPPED_PREFIXES] is where those come back out, and it *does* apply a dot + * boundary, because there the entries are whole package roots. + */ +internal object ChinaPackageMatcher { + + /** + * Whether [packageName] is never treated as domestic, no matter which + * classes or SDKs it ships. + */ + fun isSkipped(packageName: String): Boolean = SKIPPED_PREFIXES.any { + packageName == it || packageName.startsWith("$it.") + } + + fun matchesKnownPrefix(name: String): Boolean = name.matches(CHINA_PACKAGE_REGEX) + + /** Normalizes a dex type descriptor such as `Lcom/tencent/Foo$Bar;`. */ + fun classNameOf(descriptor: String): String = descriptor + .removeSurrounding("L", ";") + .replace('/', '.') + .replace('$', '.') + + private val SKIPPED_PREFIXES = listOf( + "com.google", + "com.android.chrome", + "com.android.vending", + "com.microsoft", + "com.apple", + "com.zhiliaoapp.musically", + // Caught by the loose "com.mx" prefix, which targets Maxthon's + // com.mx.browser. MX Player is unrelated. + "com.mxtech", + // Caught by the loose "com.stub" prefix, which targets the + // com.stub.StubApp packer. StubHub is unrelated. + "com.stubhub", + ) + + private val CHINA_PACKAGE_REGEX = listOf( + "com.tencent", + "com.alibaba", + "com.umeng", + "com.qihoo", + "com.ali", + "com.alipay", + "com.amap", + "com.sina", + "com.weibo", + "com.vivo", + "com.xiaomi", + "com.huawei", + "com.taobao", + "com.secneo", + "s.h.e.l.l", + "com.stub", + "com.kiwisec", + "com.secshell", + "com.wrapper", + "cn.securitystack", + "com.mogosec", + "com.secoen", + "com.netease", + "com.mx", + "com.qq.e", + "com.baidu", + "com.bytedance", + "com.bugly", + "com.miui", + "com.oppo", + "com.coloros", + "com.iqoo", + "com.meizu", + "com.gionee", + "cn.nubia", + "com.oplus", + "andes.oplus", + "com.unionpay", + "cn.wps", + ).joinToString("|", prefix = "(", postfix = ").*") { prefix -> + Regex.escape(prefix) + }.toRegex() +} diff --git a/android/app/src/main/kotlin/com/follow/clash/packages/PackageResolver.kt b/android/app/src/main/kotlin/com/follow/clash/packages/PackageResolver.kt index 0868553710..c3d6b4fb80 100644 --- a/android/app/src/main/kotlin/com/follow/clash/packages/PackageResolver.kt +++ b/android/app/src/main/kotlin/com/follow/clash/packages/PackageResolver.kt @@ -52,16 +52,16 @@ internal class PackageResolver( } private fun isChinaPackage(packageName: String): Boolean { - if (SKIPPED_PREFIXES.any { packageName == it || packageName.startsWith("$it.") }) { + if (ChinaPackageMatcher.isSkipped(packageName)) { return false } - if (packageName.matches(CHINA_PACKAGE_REGEX)) { + if (ChinaPackageMatcher.matchesKnownPrefix(packageName)) { return true } return runCatching { val packageInfo = getPackageInfo(packageName) - packageInfo.componentNames().any { it.matches(CHINA_PACKAGE_REGEX) } || + packageInfo.componentNames().any(ChinaPackageMatcher::matchesKnownPrefix) || packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true }.getOrDefault(false) } @@ -100,11 +100,9 @@ internal class PackageResolver( DexBackedDexFile.fromInputStream(null, input) } dexFile.classes.any { clazz -> - clazz.type - .removeSurrounding("L", ";") - .replace('/', '.') - .replace('$', '.') - .matches(CHINA_PACKAGE_REGEX) + ChinaPackageMatcher.matchesKnownPrefix( + ChinaPackageMatcher.classNameOf(clazz.type), + ) } } } @@ -117,58 +115,5 @@ internal class PackageResolver( PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - - val SKIPPED_PREFIXES = listOf( - "com.google", - "com.android.chrome", - "com.android.vending", - "com.microsoft", - "com.apple", - "com.zhiliaoapp.musically", - ) - - val CHINA_PACKAGE_REGEX = listOf( - "com.tencent", - "com.alibaba", - "com.umeng", - "com.qihoo", - "com.ali", - "com.alipay", - "com.amap", - "com.sina", - "com.weibo", - "com.vivo", - "com.xiaomi", - "com.huawei", - "com.taobao", - "com.secneo", - "s.h.e.l.l", - "com.stub", - "com.kiwisec", - "com.secshell", - "com.wrapper", - "cn.securitystack", - "com.mogosec", - "com.secoen", - "com.netease", - "com.mx", - "com.qq.e", - "com.baidu", - "com.bytedance", - "com.bugly", - "com.miui", - "com.oppo", - "com.coloros", - "com.iqoo", - "com.meizu", - "com.gionee", - "cn.nubia", - "com.oplus", - "andes.oplus", - "com.unionpay", - "cn.wps", - ).joinToString("|", prefix = "(", postfix = ").*") { prefix -> - Regex.escape(prefix) - }.toRegex() } } diff --git a/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt b/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt index 505d485ee9..7372576ff3 100644 --- a/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt +++ b/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt @@ -8,6 +8,8 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.VpnService import android.os.Build +import android.os.Handler +import android.os.Looper import android.os.PowerManager import android.provider.Settings import androidx.core.app.ActivityCompat @@ -20,6 +22,7 @@ import androidx.core.net.toUri import com.follow.clash.R import com.follow.clash.common.Components import com.follow.clash.common.GlobalState +import com.follow.clash.common.PendingCallback import com.follow.clash.common.QuickAction import com.follow.clash.common.quickIntent import com.follow.clash.getPackageIconPath @@ -32,6 +35,7 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -42,13 +46,21 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware private var activity: Activity? = null + private var activityBinding: ActivityPluginBinding? = null + + private val activityResultListener = + PluginRegistry.ActivityResultListener(::onActivityResult) + + private val permissionsResultListener = + PluginRegistry.RequestPermissionsResultListener(::onRequestPermissionsResultListener) + private lateinit var channel: MethodChannel private lateinit var scope: CoroutineScope - private var vpnPrepareCallback: ((Boolean) -> Unit)? = null + private val vpnPrepareCallback = PendingCallback() - private var requestNotificationCallback: ((Boolean) -> Unit)? = null + private val requestNotificationCallback = PendingCallback() private var isRequestingNotificationPermission = false @@ -63,7 +75,30 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware private var skipNotificationPermissionRequest = false - override fun onMethodCall(call: MethodCall, result: Result) { + private val mainHandler = Handler(Looper.getMainLooper()) + + /** + * Runs [block] on the main thread. + * + * The permission and consent hops below touch the Activity — starting an + * activity for result, raising a permission prompt — and read the state that + * tracks whether one is already up. Their callers are coroutines on + * [Dispatchers.Default], while the answers come back on the main thread + * through the ActivityAware listeners, so main is the one thread both ends + * can agree on. A plain main-looper post rather than the plugin scope: the + * scope is cancelled when the engine detaches, and a request dropped there + * would leave its caller waiting for a callback that can no longer run. + */ + private fun onMainThread(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post(block) + } + } + + override fun onMethodCall(call: MethodCall, rawResult: Result) { + val result = MainThreadResult(rawResult) when (call.method) { "moveTaskToBack" -> { activity?.moveTaskToBack(true) @@ -207,9 +242,8 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware task?.setExcludeFromRecents(value ?: false) } - fun requestNotificationPermission(callback: (Boolean) -> Unit) { - requestNotificationCallback?.invoke(false) - requestNotificationCallback = callback + fun requestNotificationPermission(callback: (Boolean) -> Unit) = onMainThread { + requestNotificationCallback.replace(callback, supersededValue = false) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val permission = ContextCompat.checkSelfPermission( GlobalState.application, @@ -217,10 +251,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware ) if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) { invokeRequestNotificationCallback(true) - return + return@onMainThread } if (isRequestingNotificationPermission) { - return + return@onMainThread } isRequestingNotificationPermission = true activity?.let { @@ -230,23 +264,21 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware NOTIFICATION_PERMISSION_REQUEST_CODE, ) } ?: invokeRequestNotificationCallback(true) - return + return@onMainThread } invokeRequestNotificationCallback(true) } private fun invokeRequestNotificationCallback(shouldStart: Boolean) { isRequestingNotificationPermission = false - requestNotificationCallback?.invoke(shouldStart) - requestNotificationCallback = null + requestNotificationCallback.resolve(shouldStart) } - fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) { - invokeVpnPrepareCallback(false) - vpnPrepareCallback = callback + fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) = onMainThread { + vpnPrepareCallback.replace(callback, supersededValue = false) if (!needPrepare) { invokeVpnPrepareCallback(true) - return + return@onMainThread } val intent = VpnService.prepare(GlobalState.application) if (intent != null) { @@ -257,20 +289,19 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware @Suppress("DEPRECATION") activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE) } - return + return@onMainThread } invokeVpnPrepareCallback(true) } - fun cancelVpnPreparation(callback: (Boolean) -> Unit) { - if (vpnPrepareCallback === callback) { - vpnPrepareCallback = null - } + // Posted rather than run where the cancellation lands, so it stays ordered + // behind the prepareVpn that installed the callback it is cancelling. + fun cancelVpnPreparation(callback: (Boolean) -> Unit) = onMainThread { + vpnPrepareCallback.cancel(callback) } private fun invokeVpnPrepareCallback(granted: Boolean) { - vpnPrepareCallback?.invoke(granted) - vpnPrepareCallback = null + vpnPrepareCallback.resolve(granted) } override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { @@ -292,13 +323,23 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware } private fun attachToActivity(binding: ActivityPluginBinding) { + detachFromActivity() + activityBinding = binding activity = binding.activity - binding.addActivityResultListener(::onActivityResult) - binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener) + binding.addActivityResultListener(activityResultListener) + binding.addRequestPermissionsResultListener(permissionsResultListener) } - override fun onDetachedFromActivityForConfigChanges() { + private fun detachFromActivity() { activity = null + val binding = activityBinding ?: return + activityBinding = null + binding.removeActivityResultListener(activityResultListener) + binding.removeRequestPermissionsResultListener(permissionsResultListener) + } + + override fun onDetachedFromActivityForConfigChanges() { + detachFromActivity() } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { @@ -307,7 +348,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware override fun onDetachedFromActivity() { channel.invokeMethod("exit", null) - activity = null + detachFromActivity() invokeVpnPrepareCallback(false) invokeRequestNotificationCallback(false) } diff --git a/android/app/src/main/kotlin/com/follow/clash/plugins/MainThreadResult.kt b/android/app/src/main/kotlin/com/follow/clash/plugins/MainThreadResult.kt new file mode 100644 index 0000000000..d2393946da --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/plugins/MainThreadResult.kt @@ -0,0 +1,37 @@ +package com.follow.clash.plugins + +import android.os.Handler +import android.os.Looper +import io.flutter.plugin.common.MethodChannel + +/** + * Delivers a [MethodChannel.Result] on the main thread, which is where Flutter + * requires it, from handlers that finish on a background dispatcher. + * + * The hop goes through the main looper rather than the plugin's coroutine scope: + * that scope is cancelled when the engine detaches, and a reply dropped by a + * cancelled scope leaves the Dart future waiting forever. A reply posted after + * the engine is gone is the messenger's problem to ignore, and it does. + */ +internal class MainThreadResult( + private val delegate: MethodChannel.Result, +) : MethodChannel.Result { + override fun success(result: Any?) = post { delegate.success(result) } + + override fun error(code: String, message: String?, details: Any?) = + post { delegate.error(code, message, details) } + + override fun notImplemented() = post { delegate.notImplemented() } + + private fun post(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post(block) + } + } + + private companion object { + val mainHandler = Handler(Looper.getMainLooper()) + } +} diff --git a/android/app/src/main/kotlin/com/follow/clash/plugins/ServicePlugin.kt b/android/app/src/main/kotlin/com/follow/clash/plugins/ServicePlugin.kt index 71333b2aff..768357404b 100644 --- a/android/app/src/main/kotlin/com/follow/clash/plugins/ServicePlugin.kt +++ b/android/app/src/main/kotlin/com/follow/clash/plugins/ServicePlugin.kt @@ -31,7 +31,11 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { ServiceController.setEventListener(null) } - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + override fun onMethodCall(call: MethodCall, rawResult: MethodChannel.Result) { + // Most handlers below reply from a scope worker on Dispatchers.Default, + // but a MethodChannel.Result has to be answered on the platform thread. + // Wrapping once here covers every branch, including notImplemented. + val result = MainThreadResult(rawResult) when (call.method) { "init" -> initialize(result) "shutdown" -> shutdown(result) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 6ccdf128ad..18fecbd96b 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -14,6 +14,9 @@ rootProject.layout.buildDirectory.value(newBuildDir) subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) project.layout.buildDirectory.value(newSubprojectBuildDir) +} + +subprojects { project.evaluationDependsOn(":app") } diff --git a/android/common/build.gradle.kts b/android/common/build.gradle.kts index 20d1afdd57..352920b367 100644 --- a/android/common/build.gradle.kts +++ b/android/common/build.gradle.kts @@ -16,6 +16,11 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + + sourceSets { + // Unit tests live under android/tests/ instead of each module's src/test. + getByName("test").java.setSrcDirs(listOf("../tests/common")) + } } kotlin { @@ -30,4 +35,5 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.crashlytics.ndk) implementation(libs.firebase.analytics) + testImplementation(libs.junit) } diff --git a/android/common/src/main/java/com/follow/clash/common/BroadcastLease.kt b/android/common/src/main/java/com/follow/clash/common/BroadcastLease.kt new file mode 100644 index 0000000000..efa6082515 --- /dev/null +++ b/android/common/src/main/java/com/follow/clash/common/BroadcastLease.kt @@ -0,0 +1,37 @@ +package com.follow.clash.common + +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Releases an Android broadcast lease exactly once. + * + * A `BroadcastReceiver.goAsync()` path holds the broadcast open until its + * `PendingResult` is finished, and finishing twice throws. Normal completion and + * the timeout watchdog both race to release it, so the winner is decided here + * rather than by whichever callback happens to run first. + * + * Releasing the lease says only that Android may stop waiting for this receiver. + * It does not cancel, reverse, or otherwise redefine the work the broadcast + * started, which keeps running under its own owner. + */ +class BroadcastLease(private val release: () -> Unit) { + private val released = AtomicBoolean(false) + + val isReleased: Boolean + get() = released.get() + + /** + * Releases the lease if it is still held, running [onRelease] first so a + * caller can report why it won. + * + * Returns whether this call is the one that released it. + */ + fun release(onRelease: () -> Unit = {}): Boolean { + if (!released.compareAndSet(false, true)) { + return false + } + onRelease() + release.invoke() + return true + } +} diff --git a/android/common/src/main/java/com/follow/clash/common/PendingCallback.kt b/android/common/src/main/java/com/follow/clash/common/PendingCallback.kt new file mode 100644 index 0000000000..d1ec3069ad --- /dev/null +++ b/android/common/src/main/java/com/follow/clash/common/PendingCallback.kt @@ -0,0 +1,57 @@ +package com.follow.clash.common + +/** + * A single-slot callback holder for request/response hops that leave the process + * and come back through an Android callback, such as a permission prompt or the + * VPN consent dialog. + * + * Only the newest request is ever pending: [replace] settles the request it + * supersedes rather than dropping it, so no caller is left waiting forever. The + * slot is cleared before the callback runs, so a callback that starts another + * request cannot resolve itself twice. + * + * The two ends of such a hop live on different threads — the request is started + * from whatever coroutine wants the permission, the answer arrives on the main + * thread through `onActivityResult` — so every read and write of the slot is a + * single atomic swap. Without that, a resolve that has already read the slot can + * clear a callback a concurrent replace installed after it, and the request that + * callback belonged to never completes: the VPN consent answer is lost and the + * lock the start path holds is never released. + * + * Callbacks run outside the lock. They resume coroutines and can start the next + * request, and holding the monitor across that would make the slot's lock part + * of every caller's lock order. + */ +class PendingCallback { + private val lock = Any() + private var callback: ((T) -> Unit)? = null + + val isPending: Boolean + get() = synchronized(lock) { callback != null } + + fun replace(next: (T) -> Unit, supersededValue: T) { + val superseded = synchronized(lock) { + val current = callback + callback = next + current + } + superseded?.invoke(supersededValue) + } + + fun resolve(value: T) { + val current = synchronized(lock) { + val current = callback ?: return + callback = null + current + } + current(value) + } + + fun cancel(target: (T) -> Unit) { + synchronized(lock) { + if (callback === target) { + callback = null + } + } + } +} diff --git a/android/common/src/main/java/com/follow/clash/common/RunIntentArbiter.kt b/android/common/src/main/java/com/follow/clash/common/RunIntentArbiter.kt new file mode 100644 index 0000000000..2655b664c5 --- /dev/null +++ b/android/common/src/main/java/com/follow/clash/common/RunIntentArbiter.kt @@ -0,0 +1,30 @@ +package com.follow.clash.common + +import java.util.concurrent.atomic.AtomicReference + +/** + * Tracks the latest requested run intent. + * + * Every request mints a fresh [Token]. Work that was started for an older token is obsolete once a + * newer request arrives, so callers check [isCurrent] before applying a result and roll back with + * [resetToStopped], which only wins while the token is still the latest one. + */ +class RunIntentArbiter(initialRunning: Boolean = false) { + class Token internal constructor( + val running: Boolean, + ) + + private val latest = AtomicReference(Token(initialRunning)) + + val isRunningRequested: Boolean + get() = latest.get().running + + fun current(): Token = latest.get() + + fun request(running: Boolean): Token = Token(running).also(latest::set) + + fun isCurrent(token: Token): Boolean = latest.get() === token + + fun resetToStopped(token: Token): Boolean = + latest.compareAndSet(token, Token(running = false)) +} diff --git a/android/core/src/main/cpp/core.cpp b/android/core/src/main/cpp/core.cpp index 816c75ffcc..52b1a2cbac 100644 --- a/android/core/src/main/cpp/core.cpp +++ b/android/core/src/main/cpp/core.cpp @@ -100,6 +100,7 @@ static void call_tun_interface_protect_impl(void *tun_interface, const int fd) { env->CallVoidMethod(static_cast(tun_interface), m_tun_interface_protect, fd); + jni_clear_exception(env); } static char * @@ -117,8 +118,13 @@ call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol, source_string, target_string, uid)); - env->DeleteLocalRef(source_string); - env->DeleteLocalRef(target_string); + jni_clear_exception(env); + if (source_string != nullptr) { + env->DeleteLocalRef(source_string); + } + if (target_string != nullptr) { + env->DeleteLocalRef(target_string); + } const auto result = get_string(package_name); if (package_name != nullptr) { env->DeleteLocalRef(package_name); @@ -132,7 +138,10 @@ static void call_invoke_interface_result_impl(void *invoke_interface, const char env->CallVoidMethod(static_cast(invoke_interface), m_invoke_interface_result, value); - env->DeleteLocalRef(value); + jni_clear_exception(env); + if (value != nullptr) { + env->DeleteLocalRef(value); + } } extern "C" diff --git a/android/core/src/main/cpp/jni_helper.cpp b/android/core/src/main/cpp/jni_helper.cpp index 1858e35c8e..b4fab311f1 100644 --- a/android/core/src/main/cpp/jni_helper.cpp +++ b/android/core/src/main/cpp/jni_helper.cpp @@ -2,6 +2,7 @@ #include #include +#include static JavaVM *global_vm; @@ -9,33 +10,86 @@ static jclass c_string; static jmethodID m_new_string; static jmethodID m_get_bytes; +static pthread_key_t detach_key; +static bool detach_key_ready; + +static void detach_current_thread(void *) { + global_vm->DetachCurrentThread(); +} + void initialize_jni(JavaVM *vm, JNIEnv *env) { global_vm = vm; + detach_key_ready = pthread_key_create(&detach_key, detach_current_thread) == 0; + c_string = reinterpret_cast(new_global(find_class("java/lang/String"))); m_new_string = find_method(c_string, "", "([B)V"); m_get_bytes = find_method(c_string, "getBytes", "()[B"); } +bool jni_clear_exception(JNIEnv *env) { + if (env->ExceptionCheck() == JNI_FALSE) { + return false; + } + env->ExceptionDescribe(); + env->ExceptionClear(); + return true; +} + +static char *empty_string() { + return static_cast(calloc(1, 1)); +} + char *jni_get_string(JNIEnv *env, jstring str) { if (str == nullptr) { - return static_cast(calloc(1, 1)); + return empty_string(); } const auto array = reinterpret_cast(env->CallObjectMethod(str, m_get_bytes)); + if (jni_clear_exception(env) || array == nullptr) { + return empty_string(); + } const int length = env->GetArrayLength(array); const auto content = static_cast(malloc(length + 1)); + if (content == nullptr) { + env->DeleteLocalRef(array); + return empty_string(); + } env->GetByteArrayRegion(array, 0, length, reinterpret_cast(content)); + if (jni_clear_exception(env)) { + // The copy did not happen, so `content` still holds whatever malloc + // handed back. Returning it would pass that heap content on as the + // string the caller asked for. + free(content); + env->DeleteLocalRef(array); + return empty_string(); + } env->DeleteLocalRef(array); content[length] = 0; return content; } jstring jni_new_string(JNIEnv *env, const char *str) { + if (str == nullptr) { + str = ""; + } const auto length = static_cast(strlen(str)); const auto array = env->NewByteArray(length); + if (jni_clear_exception(env) || array == nullptr) { + return env->NewStringUTF(""); + } env->SetByteArrayRegion(array, 0, length, reinterpret_cast(str)); + if (jni_clear_exception(env)) { + // Calling NewObject with an exception still pending is undefined, and + // the array it would read from was not filled in anyway. + env->DeleteLocalRef(array); + return env->NewStringUTF(""); + } const auto result = reinterpret_cast(env->NewObject(c_string, m_new_string, array)); + jni_clear_exception(env); env->DeleteLocalRef(array); + if (result == nullptr) { + return env->NewStringUTF(""); + } return result; } @@ -44,9 +98,13 @@ void jni_attach_thread(scoped_jni *jni) { jni->require_release = 0; return; } - if (global_vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) { + if (global_vm->AttachCurrentThreadAsDaemon(&jni->env, nullptr) != JNI_OK) { abort(); } + if (detach_key_ready && pthread_setspecific(detach_key, jni->env) == 0) { + jni->require_release = 0; + return; + } jni->require_release = 1; } diff --git a/android/core/src/main/cpp/jni_helper.h b/android/core/src/main/cpp/jni_helper.h index 9d89d9ff0f..90e74b685c 100644 --- a/android/core/src/main/cpp/jni_helper.h +++ b/android/core/src/main/cpp/jni_helper.h @@ -19,6 +19,8 @@ extern void jni_detach_thread(const scoped_jni *env); extern void release_string(char **value); +extern bool jni_clear_exception(JNIEnv *env); + #define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \ scoped_jni _jni{}; \ jni_attach_thread(&_jni); \ diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index c51cabf82c..d6eaaf7b86 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -9,6 +9,8 @@ annotationJvm = "1.9.1" coreSplashscreen = "1.0.1" gson = "2.13.1" smaliDexlib2 = "3.0.9" +junit = "4.13.2" +kotlinxCoroutines = "1.10.2" [libraries] androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } @@ -18,4 +20,6 @@ firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } smali-dexlib2 = { module = "com.android.tools.smali:smali-dexlib2", version.ref = "smaliDexlib2" } diff --git a/android/service/build.gradle.kts b/android/service/build.gradle.kts index 6fe7ad719a..d712c46ab1 100644 --- a/android/service/build.gradle.kts +++ b/android/service/build.gradle.kts @@ -17,6 +17,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + sourceSets { + // Unit tests live under android/tests/ instead of each module's src/test. + getByName("test").java.setSrcDirs(listOf("../tests/service")) + } } kotlin { @@ -30,4 +34,6 @@ dependencies { implementation(project(":common")) implementation(libs.gson) implementation(libs.androidx.core) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/android/service/src/main/java/com/follow/clash/service/VpnService.kt b/android/service/src/main/java/com/follow/clash/service/VpnService.kt index c88f6d0e76..e16a9f49eb 100644 --- a/android/service/src/main/java/com/follow/clash/service/VpnService.kt +++ b/android/service/src/main/java/com/follow/clash/service/VpnService.kt @@ -55,12 +55,14 @@ class VpnService : SystemVpnService(), ManagedService { if (nextUid == -1) { return "" } - return uidPackageNameMap.getOrPut(nextUid) { - packageManager - .getPackagesForUid(nextUid) - ?.firstOrNull() - .orEmpty() - } + val cached = uidPackageNameMap[nextUid] + if (cached != null) return cached + val packageName = packageManager + .getPackagesForUid(nextUid) + ?.firstOrNull() + ?.takeIf { it.isNotEmpty() } + .orEmpty() + return uidPackageNameMap.putIfAbsent(nextUid, packageName) ?: packageName } private val VpnOptions.tunAddress diff --git a/android/service/src/main/java/com/follow/clash/service/modules/NetworkObserveModule.kt b/android/service/src/main/java/com/follow/clash/service/modules/NetworkObserveModule.kt index 153ea6ebe6..c907912136 100644 --- a/android/service/src/main/java/com/follow/clash/service/modules/NetworkObserveModule.kt +++ b/android/service/src/main/java/com/follow/clash/service/modules/NetworkObserveModule.kt @@ -9,6 +9,8 @@ import android.net.NetworkCapabilities.TRANSPORT_SATELLITE import android.net.NetworkCapabilities.TRANSPORT_USB import android.net.NetworkRequest import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.core.content.getSystemService import com.follow.clash.core.Core import java.net.Inet4Address @@ -30,6 +32,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul private val connectivity by lazy { service.getSystemService() } + private val mainHandler = Handler(Looper.getMainLooper()) private var currentDnsList = listOf() private val request = NetworkRequest.Builder().apply { @@ -48,8 +51,16 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul } override fun onLosing(network: Network, maxMsToLive: Int) { - networkInfos[network]?.losingUntilMillis = System.currentTimeMillis() + maxMsToLive + val info = networkInfos[network] ?: return + info.losingUntilMillis = System.currentTimeMillis() + maxMsToLive updateDns() + if (maxMsToLive > 0) { + mainHandler.postDelayed({ + if (networkInfos.containsKey(network)) { + updateDns() + } + }, maxMsToLive.toLong() + 50) + } } override fun onLost(network: Network) { @@ -104,6 +115,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul } override fun stop() { + mainHandler.removeCallbacksAndMessages(null) try { connectivity?.unregisterNetworkCallback(callback) } finally { diff --git a/android/tests/app/ChinaPackageMatcherTest.kt b/android/tests/app/ChinaPackageMatcherTest.kt new file mode 100644 index 0000000000..9936c49416 --- /dev/null +++ b/android/tests/app/ChinaPackageMatcherTest.kt @@ -0,0 +1,130 @@ +package com.follow.clash.packages + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChinaPackageMatcherTest { + + @Test + fun `known vendors and SDKs match`() { + val names = listOf( + "com.tencent.mm", + "com.alipay.android.app", + "com.taobao.taobao", + "com.baidu.searchbox", + "com.bytedance.sdk.openadsdk", + "com.netease.cloudmusic", + "com.unionpay.tsmservice", + "cn.wps.moffice", + "andes.oplus.internal", + ) + for (name in names) { + assertTrue(name, ChinaPackageMatcher.matchesKnownPrefix(name)) + } + } + + @Test + fun `packer signatures used as class names match`() { + val classNames = listOf( + "com.secneo.apkwrapper.H", + "s.h.e.l.l.S", + "com.stub.StubApp", + "com.kiwisec.KiwiSecApplication", + "com.secshell.shellwrapper.SecAppWrapper", + "com.wrapper.proxyapplication.WrapperProxyApplication", + "cn.securitystack.stack.StackApplication", + ) + for (name in classNames) { + assertTrue(name, ChinaPackageMatcher.matchesKnownPrefix(name)) + } + } + + /** + * The prefixes carry no dot boundary on purpose. Adding one would look + * tidier and would silently stop detecting 360 and the Alibaba clouds, + * whose packages extend the prefix without a separator. + */ + @Test + fun `prefixes deliberately match without a separator`() { + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.qihoo360.mobilesafe")) + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.aliyun.linkcard")) + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.alimama.moon")) + } + + @Test + fun `a bare prefix matches on its own`() { + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.tencent")) + } + + @Test + fun `unrelated packages do not match`() { + val names = listOf( + "org.mozilla.firefox", + "com.spotify.music", + "de.telekom.mail", + "com.whatsapp", + ) + for (name in names) { + assertFalse(name, ChinaPackageMatcher.matchesKnownPrefix(name)) + } + } + + /** + * These two do match a prefix, which is exactly why they have to be skipped + * explicitly: MX Player is caught by `com.mx` (meant for Maxthon) and + * StubHub by `com.stub` (meant for the StubApp packer). + */ + @Test + fun `loose prefixes drag in unrelated apps that the skip list removes`() { + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.mxtech.videoplayer.ad")) + assertTrue(ChinaPackageMatcher.isSkipped("com.mxtech.videoplayer.ad")) + + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.stubhub")) + assertTrue(ChinaPackageMatcher.isSkipped("com.stubhub")) + } + + @Test + fun `the intended owners of those prefixes still match and are not skipped`() { + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.mx.browser")) + assertFalse(ChinaPackageMatcher.isSkipped("com.mx.browser")) + + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.stub.StubApp")) + assertFalse(ChinaPackageMatcher.isSkipped("com.stub.StubApp")) + } + + @Test + fun `the skip list applies a dot boundary`() { + assertTrue(ChinaPackageMatcher.isSkipped("com.google")) + assertTrue(ChinaPackageMatcher.isSkipped("com.google.android.gms")) + assertFalse( + "com.googlefoo is a different vendor", + ChinaPackageMatcher.isSkipped("com.googlefoo"), + ) + } + + @Test + fun `skipping wins over a matching prefix`() { + // TikTok ships domestic SDKs but must stay out of the domestic list. + assertTrue(ChinaPackageMatcher.isSkipped("com.zhiliaoapp.musically")) + } + + @Test + fun `dex descriptors are normalized before matching`() { + assertEquals( + "com.tencent.mm.Foo.Bar", + ChinaPackageMatcher.classNameOf("Lcom/tencent/mm/Foo\$Bar;"), + ) + assertTrue( + ChinaPackageMatcher.matchesKnownPrefix( + ChinaPackageMatcher.classNameOf("Lcom/qihoo360/replugin/Entry;"), + ), + ) + } + + @Test + fun `an already normalized name survives normalization`() { + assertEquals("com.example.Foo", ChinaPackageMatcher.classNameOf("com.example.Foo")) + } +} diff --git a/android/tests/app/ServiceStateMachineTest.kt b/android/tests/app/ServiceStateMachineTest.kt new file mode 100644 index 0000000000..b52aa7cb18 --- /dev/null +++ b/android/tests/app/ServiceStateMachineTest.kt @@ -0,0 +1,611 @@ +package com.follow.clash + +import com.follow.clash.common.AccessControlMode +import com.follow.clash.models.SetupParams +import com.follow.clash.models.SharedState +import com.follow.clash.service.models.AccessControlProps +import com.follow.clash.service.models.NotificationParams +import com.follow.clash.service.models.VpnOptions +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +private fun vpnOptions(enable: Boolean = true) = VpnOptions( + enable = enable, + port = 7890, + ipv6 = false, + dnsHijacking = false, + accessControlProps = AccessControlProps( + enable = false, + mode = AccessControlMode.ACCEPT_SELECTED, + acceptList = emptyList(), + rejectList = emptyList(), + ), + allowBypass = false, + systemProxy = true, + bypassDomain = emptyList(), + stack = "gvisor", + routeAddress = emptyList(), +) + +private fun configuredState(enable: Boolean = true) = SharedState( + vpnOptions = vpnOptions(enable), + setupParams = SetupParams(testUrl = "https://example.com", selectedMap = emptyMap()), +) + +private class FakeTile : TileGateway { + var startCount = 0 + var stopCount = 0 + + override fun handleStart() { + startCount++ + } + + override fun handleStop() { + stopCount++ + } +} + +private class FakeApp( + private val notificationGranted: Boolean = true, + private val vpnGranted: Boolean = true, + private val holdVpnPreparation: Boolean = false, +) : AppGateway { + var beforeVpnPrepared: (() -> Unit)? = null + var cancelledPreparations = 0 + + private var heldCallback: ((Boolean) -> Unit)? = null + + override fun requestNotificationPermission(callback: (Boolean) -> Unit) = + callback(notificationGranted) + + override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) { + beforeVpnPrepared?.invoke() + if (holdVpnPreparation) { + heldCallback = callback + return + } + callback(vpnGranted) + } + + override fun cancelVpnPreparation(callback: (Boolean) -> Unit) { + cancelledPreparations++ + if (heldCallback === callback) { + heldCallback = null + } + } +} + +private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost { + var storedSharedState = configuredState() + var setupResult: Result = Result.success("") + var startResult = 1_700_000_000_000L + var vpnPermissionGranted = true + var vpnServiceActive = true + var tile: TileGateway? = null + var app: AppGateway? = null + var beforeStartService: (() -> Unit)? = null + + override var runTimeMillis = 0L + override val homeDirPath = "/data/user/0/com.follow.clash/files" + override val sdkInt = 34 + + val toasts = mutableListOf() + val logs = mutableListOf() + val notificationParams = mutableListOf() + val crashlytics = mutableListOf() + var setupCalls = 0 + var startCalls = 0 + var stopCalls = 0 + var lastInitParams: String? = null + var lastSetupParams: String? = null + + override fun log(message: String) { + logs += message + } + + override fun showToast(message: String) { + toasts += message + } + + override fun setCrashlytics(enabled: Boolean) { + crashlytics += enabled + } + + override fun updateNotificationParams(params: NotificationParams) { + notificationParams += params + } + + override fun loadSharedState(): SharedState = storedSharedState + + override fun isVpnPermissionGranted(): Boolean = vpnPermissionGranted + + override fun tile(): TileGateway? = tile + + override fun app(): AppGateway? = app + + override suspend fun quickSetup(initParams: String, setupParams: String): Result { + setupCalls++ + lastInitParams = initParams + lastSetupParams = setupParams + return setupResult + } + + override suspend fun startService(options: VpnOptions): Long { + startCalls++ + beforeStartService?.invoke() + runTimeMillis = startResult + return startResult + } + + override suspend fun stopService() { + stopCalls++ + runTimeMillis = 0L + } + + override suspend fun isVpnServiceActive(): Boolean = vpnServiceActive +} + +@OptIn(ExperimentalCoroutinesApi::class) +class ServiceStateMachineTest { + + @Test + fun `initParams spells the keys the Go wrapper expects`() { + val json = ServiceStateMachine.initParams("/files", 34) + + assertEquals("""{"home-dir":"/files","version":34}""", json) + } + + @Test + fun `notification params come straight off the shared state`() { + val params = ServiceStateMachine.notificationParams( + SharedState( + currentProfileName = "Work", + stopText = "Disconnect", + onlyStatisticsProxy = true, + ), + ) + + assertEquals(NotificationParams("Work", "Disconnect", true), params) + } + + @Test + fun `refresh reports STARTED only while the service has a run time`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + assertEquals(0L, machine.refresh()) + assertEquals(RunState.STOPPED, machine.runState.value) + + host.runTimeMillis = 42L + assertEquals(42L, machine.refresh()) + assertEquals(RunState.STARTED, machine.runState.value) + } + + @Test + fun `a start request drives the service to STARTED`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + + assertTrue(machine.requestStart().await()) + assertEquals(RunState.STARTED, machine.runState.value) + assertEquals(1, host.startCalls) + } + + @Test + fun `a start that the service refuses settles back to STOPPED`() = runTest { + val host = FakeHost(backgroundScope) + host.startResult = 0L + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + + assertFalse(machine.requestStart().await()) + assertEquals(RunState.STOPPED, machine.runState.value) + } + + @Test + fun `a start without stored vpn options never reaches the service`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + assertFalse(machine.requestStart().await()) + assertEquals(0, host.startCalls) + } + + @Test + fun `a denied notification permission cancels the start`() = runTest { + val host = FakeHost(backgroundScope) + host.app = FakeApp(notificationGranted = false) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + + assertFalse(machine.requestStart().await()) + assertEquals(0, host.startCalls) + assertEquals(RunState.STOPPED, machine.runState.value) + } + + @Test + fun `a denied vpn permission cancels the start`() = runTest { + val host = FakeHost(backgroundScope) + host.app = FakeApp(vpnGranted = false) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + + assertFalse(machine.requestStart().await()) + assertEquals(0, host.startCalls) + } + + /** + * Without a foreground app there is nobody to show the system consent dialog, so the machine + * has to explain the refusal itself. + */ + @Test + fun `a missing vpn permission is reported when no app is attached`() = runTest { + val host = FakeHost(backgroundScope) + host.vpnPermissionGranted = false + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + + assertFalse(machine.requestStart().await()) + assertTrue(host.toasts.contains(VPN_PERMISSION_MESSAGE)) + } + + @Test + fun `a proxy-only start does not need the vpn permission`() = runTest { + val host = FakeHost(backgroundScope) + host.vpnPermissionGranted = false + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState(enable = false)) + + assertTrue(machine.requestStart().await()) + assertEquals(1, host.startCalls) + } + + @Test + fun `a stop request drives the service to STOPPED`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + + assertTrue(machine.requestStop().await()) + assertEquals(RunState.STOPPED, machine.runState.value) + assertEquals(1, host.stopCalls) + } + + @Test + fun `stopping an already stopped service touches nothing`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + assertTrue(machine.requestStop().await()) + assertEquals(0, host.stopCalls) + } + + @Test + fun `a stop that lands mid-preparation keeps the service stopped`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + val app = FakeApp() + host.app = app + app.beforeVpnPrepared = { machine.requestStop() } + + assertFalse(machine.requestStart().await()) + assertEquals(0, host.startCalls) + assertEquals(RunState.STOPPED, machine.runState.value) + } + + @Test + fun `a start reports failure when a stop overtakes the service call`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + host.beforeStartService = { machine.requestStop() } + + assertFalse(machine.requestStart().await()) + } + + @Test + fun `handleServiceLost clears the state for the token that observed the loss`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + + val token = machine.captureRequestToken() + host.runTimeMillis = 0L + machine.handleServiceLost(token) + + assertEquals(RunState.STOPPED, machine.runState.value) + } + + @Test + fun `handleServiceLost keeps the intent of a start that raced ahead of it`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + val staleToken = machine.captureRequestToken() + + machine.requestStart().await() + host.runTimeMillis = 0L + machine.handleServiceLost(staleToken) + + assertEquals(RunState.STARTED, machine.runState.value) + } + + @Test + fun `handleServiceLost ignores a service that is running again`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + + machine.handleServiceLost(machine.captureRequestToken()) + + assertEquals(RunState.STARTED, machine.runState.value) + } + + @Test + fun `handleStartAction hands the start to the tile when one is attached`() = runTest { + val host = FakeHost(backgroundScope) + val tile = FakeTile() + host.tile = tile + val machine = ServiceStateMachine(host) + + machine.handleStartAction() + + assertEquals(1, tile.startCount) + assertEquals(0, host.setupCalls) + } + + @Test + fun `handleStartAction is a no-op while a run is already requested`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + val startsBefore = host.startCalls + + machine.handleStartAction() + + assertEquals(startsBefore, host.startCalls) + assertEquals(0, host.setupCalls) + } + + @Test + fun `handleStartAction reports a missing configuration`() = runTest { + val host = FakeHost(backgroundScope) + host.storedSharedState = SharedState() + val machine = ServiceStateMachine(host) + + machine.handleStartAction() + + assertEquals(listOf(MISSING_CONFIG_MESSAGE), host.toasts) + assertEquals(0, host.setupCalls) + } + + @Test + fun `handleStartAction loads the stored configuration and sets the core up`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + machine.handleStartAction() + + assertEquals(1, host.setupCalls) + assertEquals(1, host.startCalls) + assertEquals(RunState.STARTED, machine.runState.value) + assertEquals( + ServiceStateMachine.initParams(host.homeDirPath, host.sdkInt), + host.lastInitParams, + ) + assertEquals(Gson().toJson(host.storedSharedState.setupParams), host.lastSetupParams) + } + + @Test + fun `a core that rejects the configuration reports its own message`() = runTest { + val host = FakeHost(backgroundScope) + host.setupResult = Result.success("proxy group not found") + val machine = ServiceStateMachine(host) + + machine.handleStartAction() + + assertTrue(host.toasts.contains("proxy group not found")) + assertEquals(0, host.startCalls) + } + + @Test + fun `a core setup that throws without a message falls back to the generic one`() = runTest { + val host = FakeHost(backgroundScope) + host.setupResult = Result.failure(RuntimeException(" ")) + val machine = ServiceStateMachine(host) + + machine.handleStartAction() + + assertTrue(host.toasts.contains(INVALID_CONFIG_MESSAGE)) + assertEquals(0, host.startCalls) + } + + @Test + fun `a service that refuses the start after a good setup is reported`() = runTest { + val host = FakeHost(backgroundScope) + host.startResult = 0L + val machine = ServiceStateMachine(host) + + machine.handleStartAction() + + assertTrue(host.toasts.contains(START_FAILED_MESSAGE)) + } + + @Test + fun `handleStopAction hands the stop to the tile when one is attached`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + val tile = FakeTile() + host.tile = tile + + machine.handleStopAction() + + assertEquals(1, tile.stopCount) + assertEquals(0, host.stopCalls) + } + + @Test + fun `handleStopAction is a no-op while nothing is running`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + machine.handleStopAction() + + assertEquals(0, host.stopCalls) + assertTrue(host.toasts.isEmpty()) + } + + @Test + fun `handleStopAction announces itself before stopping`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState().copy(stopTip = "Stopping...")) + machine.requestStart().await() + + machine.handleStopAction() + + assertEquals(listOf("Stopping..."), host.toasts) + assertEquals(1, host.stopCalls) + } + + @Test + fun `handleToggleAction starts when stopped and stops when started`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + machine.handleToggleAction() + assertEquals(1, host.startCalls) + + machine.handleToggleAction() + assertEquals(1, host.stopCalls) + } + + @Test + fun `a revoke is ignored while no vpn service is active`() = runTest { + val host = FakeHost(backgroundScope) + host.vpnServiceActive = false + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + + machine.handleVpnRevokeAction() + + assertEquals(0, host.stopCalls) + } + + @Test + fun `a revoke stops the active vpn service`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + + machine.handleVpnRevokeAction() + + assertEquals(1, host.stopCalls) + } + + @Test + fun `syncSharedState pushes crashlytics and the notification straight through`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + machine.syncSharedState( + SharedState( + crashlytics = false, + currentProfileName = "Work", + stopText = "Disconnect", + onlyStatisticsProxy = true, + ), + ) + + assertEquals(listOf(false), host.crashlytics) + assertEquals(listOf(NotificationParams("Work", "Disconnect", true)), host.notificationParams) + } + + @Test + fun `a start that a stop overtakes never announces STARTED`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + val states = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + machine.runState.toList(states) + } + host.beforeStartService = { machine.requestStop() } + + assertFalse(machine.requestStart().await()) + testScheduler.runCurrent() + + assertFalse(states.contains(RunState.STARTED)) + assertEquals(RunState.STOPPED, machine.runState.value) + assertEquals(0L, host.runTimeMillis) + } + + @Test + fun `a start rolled back after the service came up stops it again`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + host.beforeStartService = { + host.app = FakeApp(vpnGranted = false) + machine.requestStart() + } + + assertFalse(machine.requestStart().await()) + testScheduler.runCurrent() + + assertEquals(1, host.stopCalls) + assertEquals(0L, host.runTimeMillis) + assertEquals(RunState.STOPPED, machine.runState.value) + assertFalse(machine.captureRequestToken().running) + } + + @Test + fun `a stop releases a start that is waiting on the vpn consent`() = runTest { + val host = FakeHost(backgroundScope) + val app = FakeApp(holdVpnPreparation = true) + host.app = app + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + + val start = machine.requestStart() + testScheduler.runCurrent() + assertEquals(0, host.startCalls) + + assertTrue(machine.requestStop().await()) + assertFalse(start.await()) + assertEquals(0, host.startCalls) + assertEquals(1, app.cancelledPreparations) + assertEquals(RunState.STOPPED, machine.runState.value) + } + + @Test + fun `a start request that throws is logged and rolled back`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + host.beforeStartService = { throw IllegalStateException("binder died") } + + assertFalse(machine.requestStart().await()) + assertTrue(host.logs.any { it.contains("binder died") }) + assertFalse(machine.captureRequestToken().running) + } +} diff --git a/android/tests/app/SharedStateTest.kt b/android/tests/app/SharedStateTest.kt new file mode 100644 index 0000000000..4fc739b00f --- /dev/null +++ b/android/tests/app/SharedStateTest.kt @@ -0,0 +1,155 @@ +package com.follow.clash.models + +import com.follow.clash.common.AccessControlMode +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * `SharedState` is written by Flutter into `FlutterSharedPreferences` and read back here, so the + * JSON spelling of every field is a cross-language contract rather than an implementation detail. + */ +class SharedStateTest { + private val gson = Gson() + + @Test + fun `setup params use the kebab-case spelling Flutter writes`() { + val json = """{"test-url":"https://example.test/204","selected-map":{"GLOBAL":"auto"}}""" + + val params = gson.fromJson(json, SetupParams::class.java) + + assertEquals("https://example.test/204", params.testUrl) + assertEquals(mapOf("GLOBAL" to "auto"), params.selectedMap) + } + + @Test + fun `setup params serialize back to the same spelling`() { + val encoded = gson.toJson( + SetupParams(testUrl = "https://example.test", selectedMap = emptyMap()), + ) + + assertTrue(encoded.contains("\"test-url\"")) + assertTrue(encoded.contains("\"selected-map\"")) + assertTrue(!encoded.contains("testUrl")) + assertTrue(!encoded.contains("selectedMap")) + } + + @Test + fun `a full payload round-trips including nested vpn options`() { + val json = """ + { + "startTip": "Starting", + "stopTip": "Stopping", + "crashlytics": false, + "currentProfileName": "Work", + "stopText": "Halt", + "onlyStatisticsProxy": true, + "vpnOptions": { + "enable": true, + "port": 7890, + "ipv6": false, + "dnsHijacking": true, + "accessControlProps": { + "enable": true, + "mode": "rejectSelected", + "acceptList": ["a.b"], + "rejectList": ["c.d"] + }, + "allowBypass": false, + "systemProxy": true, + "bypassDomain": ["example.test"], + "stack": "gvisor", + "routeAddress": ["0.0.0.0/0"] + }, + "setupParams": { + "test-url": "https://example.test/204", + "selected-map": {"GLOBAL": "auto"} + } + } + """.trimIndent() + + val state = gson.fromJson(json, SharedState::class.java) + + assertEquals("Starting", state.startTip) + assertEquals("Work", state.currentProfileName) + assertEquals(false, state.crashlytics) + assertEquals(true, state.onlyStatisticsProxy) + assertEquals(7890, state.vpnOptions?.port) + assertEquals("gvisor", state.vpnOptions?.stack) + assertEquals( + AccessControlMode.REJECT_SELECTED, + state.vpnOptions?.accessControlProps?.mode, + ) + assertEquals(listOf("0.0.0.0/0"), state.vpnOptions?.routeAddress) + assertEquals("https://example.test/204", state.setupParams?.testUrl) + } + + @Test + fun `the constructed default keeps every fallback Flutter relies on`() { + val defaults = SharedState() + + assertEquals("FlClash", defaults.currentProfileName) + assertEquals("Stop", defaults.stopText) + assertEquals(true, defaults.crashlytics) + assertEquals(false, defaults.onlyStatisticsProxy) + assertNull(defaults.vpnOptions) + assertNull(defaults.setupParams) + } + + @Test + fun `an empty document still yields the declared defaults`() { + // Every SharedState parameter has a default, so Kotlin emits a no-arg constructor + // that Gson uses instead of its Unsafe fallback. Losing a default on any one + // parameter would silently turn the absent fields below into nulls. + val state = gson.fromJson("{}", SharedState::class.java) + + assertNotNull(state) + assertEquals("Starting VPN...", state.startTip) + assertEquals("FlClash", state.currentProfileName) + assertEquals(true, state.crashlytics) + assertNull(state.vpnOptions) + assertNull(state.setupParams) + } + + @Test + fun `a partial document keeps defaults for the fields it omits`() { + val state = gson.fromJson("""{"currentProfileName":"Work"}""", SharedState::class.java) + + assertEquals("Work", state.currentProfileName) + assertEquals("Stop", state.stopText) + assertEquals("Stopping VPN...", state.stopTip) + } + + @Test + fun `setup params have no defaults so absent fields decode as null`() { + // SetupParams declares no default values, so Gson builds it through Unsafe and + // leaves missing fields null despite the non-nullable Kotlin types. + val params = gson.fromJson("{}", SetupParams::class.java) + + @Suppress("SENSELESS_COMPARISON") + assertTrue(params.testUrl == null) + + @Suppress("SENSELESS_COMPARISON") + assertTrue(params.selectedMap == null) + } + + @Test + fun `a null json document decodes to null so the caller can fall back`() { + assertNull(gson.fromJson("null", SharedState::class.java)) + assertNull(gson.fromJson(null as String?, SharedState::class.java)) + } + + @Test + fun `an access control payload without a matching mode decodes to null`() { + val json = """ + {"vpnOptions":{"accessControlProps":{"mode":"unknownMode"}}} + """.trimIndent() + + val state = gson.fromJson(json, SharedState::class.java) + + assertNull(state.vpnOptions?.accessControlProps?.mode) + } +} diff --git a/android/tests/common/BroadcastLeaseTest.kt b/android/tests/common/BroadcastLeaseTest.kt new file mode 100644 index 0000000000..3732c4c83c --- /dev/null +++ b/android/tests/common/BroadcastLeaseTest.kt @@ -0,0 +1,87 @@ +package com.follow.clash.common + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BroadcastLeaseTest { + @Test + fun `starts held`() { + val lease = BroadcastLease { } + + assertFalse(lease.isReleased) + } + + @Test + fun `the first release runs the release action and reports the win`() { + var releases = 0 + val lease = BroadcastLease { releases++ } + + assertTrue(lease.release()) + assertEquals(1, releases) + assertTrue(lease.isReleased) + } + + @Test + fun `a second release neither runs nor claims the win`() { + var releases = 0 + val lease = BroadcastLease { releases++ } + lease.release() + + assertFalse(lease.release()) + assertEquals(1, releases) + } + + @Test + fun `the reason runs only for the caller that wins`() { + val reasons = mutableListOf() + val lease = BroadcastLease { } + + lease.release { reasons.add("timeout") } + lease.release { reasons.add("completion") } + + assertEquals(listOf("timeout"), reasons) + } + + @Test + fun `the reason runs before the lease is released`() { + val order = mutableListOf() + val lease = BroadcastLease { order.add("release") } + + lease.release { order.add("reason") } + + assertEquals(listOf("reason", "release"), order) + } + + @Test + fun `only one of many concurrent releases wins`() { + val threads = 16 + val releases = AtomicInteger(0) + val wins = AtomicInteger(0) + val lease = BroadcastLease { releases.incrementAndGet() } + val start = CountDownLatch(1) + val done = CountDownLatch(threads) + val pool = Executors.newFixedThreadPool(threads) + + repeat(threads) { + pool.execute { + start.await() + if (lease.release()) { + wins.incrementAndGet() + } + done.countDown() + } + } + start.countDown() + + assertTrue(done.await(5, TimeUnit.SECONDS)) + pool.shutdown() + assertEquals(1, releases.get()) + assertEquals(1, wins.get()) + } +} diff --git a/android/tests/common/EnumsTest.kt b/android/tests/common/EnumsTest.kt new file mode 100644 index 0000000000..96889b66ad --- /dev/null +++ b/android/tests/common/EnumsTest.kt @@ -0,0 +1,47 @@ +package com.follow.clash.common + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The Flutter layer exchanges these enums as JSON, so the serialized spelling is a cross-language + * contract rather than an implementation detail. + */ +class EnumsTest { + private val gson = Gson() + + @Test + fun `access control mode serializes to the Dart spelling`() { + assertEquals("\"acceptSelected\"", gson.toJson(AccessControlMode.ACCEPT_SELECTED)) + assertEquals("\"rejectSelected\"", gson.toJson(AccessControlMode.REJECT_SELECTED)) + } + + @Test + fun `access control mode deserializes from the Dart spelling`() { + assertEquals( + AccessControlMode.ACCEPT_SELECTED, + gson.fromJson("\"acceptSelected\"", AccessControlMode::class.java), + ) + assertEquals( + AccessControlMode.REJECT_SELECTED, + gson.fromJson("\"rejectSelected\"", AccessControlMode::class.java), + ) + } + + @Test + fun `quick action names back the intent action suffixes`() { + assertEquals( + listOf("STOP", "START", "TOGGLE"), + QuickAction.entries.map { it.name }, + ) + } + + @Test + fun `broadcast action names back the intent action suffixes`() { + assertEquals( + listOf("VPN_START_REQUESTED", "VPN_REVOKED"), + BroadcastAction.entries.map { it.name }, + ) + } +} diff --git a/android/tests/common/PendingCallbackTest.kt b/android/tests/common/PendingCallbackTest.kt new file mode 100644 index 0000000000..60340c5429 --- /dev/null +++ b/android/tests/common/PendingCallbackTest.kt @@ -0,0 +1,164 @@ +package com.follow.clash.common + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PendingCallbackTest { + @Test + fun `starts empty`() { + val slot = PendingCallback() + + assertFalse(slot.isPending) + } + + @Test + fun `replace leaves the new callback pending`() { + val slot = PendingCallback() + + slot.replace({ }, supersededValue = false) + + assertTrue(slot.isPending) + } + + @Test + fun `resolve delivers the value once and empties the slot`() { + val slot = PendingCallback() + val received = mutableListOf() + slot.replace({ received.add(it) }, supersededValue = false) + + slot.resolve(true) + slot.resolve(true) + + assertEquals(listOf(true), received) + assertFalse(slot.isPending) + } + + @Test + fun `replace settles the request it supersedes`() { + val slot = PendingCallback() + val first = mutableListOf() + val second = mutableListOf() + slot.replace({ first.add(it) }, supersededValue = false) + + slot.replace({ second.add(it) }, supersededValue = false) + + assertEquals(listOf(false), first) + assertTrue(second.isEmpty()) + + slot.resolve(true) + + assertEquals(listOf(false), first) + assertEquals(listOf(true), second) + } + + @Test + fun `resolve on an empty slot is inert`() { + val slot = PendingCallback() + + slot.resolve(true) + + assertFalse(slot.isPending) + } + + @Test + fun `cancel drops the matching callback without invoking it`() { + val slot = PendingCallback() + val received = mutableListOf() + val callback: (Boolean) -> Unit = { received.add(it) } + slot.replace(callback, supersededValue = false) + + slot.cancel(callback) + + assertFalse(slot.isPending) + assertTrue(received.isEmpty()) + } + + @Test + fun `cancel keeps a callback that is no longer the pending one`() { + val slot = PendingCallback() + val stale = mutableListOf() + val current = mutableListOf() + val staleCallback: (Boolean) -> Unit = { stale.add(it) } + slot.replace(staleCallback, supersededValue = false) + slot.replace({ current.add(it) }, supersededValue = false) + + slot.cancel(staleCallback) + + assertTrue(slot.isPending) + + slot.resolve(true) + + assertEquals(listOf(false), stale) + assertEquals(listOf(true), current) + } + + @Test + fun `a callback that starts a new request is not resolved twice`() { + val slot = PendingCallback() + val outer = mutableListOf() + val inner = mutableListOf() + slot.replace( + { + outer.add(it) + slot.replace({ value -> inner.add(value) }, supersededValue = false) + }, + supersededValue = false, + ) + + slot.resolve(true) + + assertEquals(listOf(true), outer) + assertTrue(inner.isEmpty()) + assertTrue(slot.isPending) + + slot.resolve(false) + + assertEquals(listOf(true), outer) + assertEquals(listOf(false), inner) + } + + /** + * The request is started off the main thread and the answer arrives on it, so + * a resolve that has already read the slot must not clear the callback a + * concurrent replace installed after that read. Losing it strands the request + * that callback belongs to, which on the VPN consent hop means the start path + * never releases its lock. + */ + @Test + fun `a callback installed while a resolve is in flight is never dropped`() { + repeat(200) { + val slot = PendingCallback() + val settled = AtomicInteger() + slot.replace({ settled.incrementAndGet() }, supersededValue = false) + + val ready = CountDownLatch(2) + val go = CountDownLatch(1) + val replacer = Thread { + ready.countDown() + go.await() + slot.replace({ settled.incrementAndGet() }, supersededValue = false) + } + val resolver = Thread { + ready.countDown() + go.await() + slot.resolve(true) + } + replacer.start() + resolver.start() + ready.await() + go.countDown() + replacer.join(TimeUnit.SECONDS.toMillis(5)) + resolver.join(TimeUnit.SECONDS.toMillis(5)) + + // Two callbacks were installed and at most one of them can still be + // pending, so every callback the slot let go of must have been called. + val pending = if (slot.isPending) 1 else 0 + assertEquals(2 - pending, settled.get()) + } + } +} diff --git a/android/tests/common/RunIntentArbiterTest.kt b/android/tests/common/RunIntentArbiterTest.kt new file mode 100644 index 0000000000..fb34c38482 --- /dev/null +++ b/android/tests/common/RunIntentArbiterTest.kt @@ -0,0 +1,137 @@ +package com.follow.clash.common + +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class RunIntentArbiterTest { + @Test + fun `starts stopped by default`() { + val arbiter = RunIntentArbiter() + + assertFalse(arbiter.isRunningRequested) + assertFalse(arbiter.current().running) + } + + @Test + fun `request publishes the new intent`() { + val arbiter = RunIntentArbiter() + + val token = arbiter.request(running = true) + + assertTrue(arbiter.isRunningRequested) + assertTrue(arbiter.isCurrent(token)) + assertSame(token, arbiter.current()) + } + + @Test + fun `each request mints a distinct token`() { + val arbiter = RunIntentArbiter() + + val first = arbiter.request(running = true) + val second = arbiter.request(running = true) + + assertNotSame(first, second) + assertFalse(arbiter.isCurrent(first)) + assertTrue(arbiter.isCurrent(second)) + } + + @Test + fun `a newer request supersedes work started for an older token`() { + val arbiter = RunIntentArbiter() + val start = arbiter.request(running = true) + + val stop = arbiter.request(running = false) + + assertFalse(arbiter.isCurrent(start)) + assertTrue(arbiter.isCurrent(stop)) + assertFalse(arbiter.isRunningRequested) + } + + @Test + fun `resetToStopped rolls back the current token`() { + val arbiter = RunIntentArbiter() + val token = arbiter.request(running = true) + + assertTrue(arbiter.resetToStopped(token)) + + assertFalse(arbiter.isRunningRequested) + assertFalse(arbiter.isCurrent(token)) + } + + @Test + fun `resetToStopped does not clobber a newer intent`() { + val arbiter = RunIntentArbiter() + val stale = arbiter.request(running = true) + val latest = arbiter.request(running = true) + + assertFalse(arbiter.resetToStopped(stale)) + + assertTrue(arbiter.isRunningRequested) + assertTrue(arbiter.isCurrent(latest)) + } + + @Test + fun `resetToStopped is not idempotent for the same token`() { + val arbiter = RunIntentArbiter() + val token = arbiter.request(running = true) + + assertTrue(arbiter.resetToStopped(token)) + assertFalse(arbiter.resetToStopped(token)) + } + + @Test + fun `a start racing ahead of a service-lost callback keeps its intent`() { + val arbiter = RunIntentArbiter() + val observed = arbiter.request(running = true) + + // The service-loss callback captured `observed`, but a new start won the race first. + val restart = arbiter.request(running = true) + val settled = arbiter.resetToStopped(observed) + + assertFalse(settled) + assertTrue(arbiter.isRunningRequested) + assertTrue(arbiter.isCurrent(restart)) + } + + @Test + fun `initialRunning seeds the first intent`() { + val arbiter = RunIntentArbiter(initialRunning = true) + + assertTrue(arbiter.isRunningRequested) + } + + @Test + fun `concurrent requests leave exactly one winning token`() { + val arbiter = RunIntentArbiter() + val threads = 8 + val executor = Executors.newFixedThreadPool(threads) + val barrier = CyclicBarrier(threads) + + try { + val tokens = (0 until threads).map { index -> + executor.submit { + barrier.await(5, TimeUnit.SECONDS) + arbiter.request(running = index % 2 == 0) + } + }.map { it.get(5, TimeUnit.SECONDS) } + + val winners = tokens.filter(arbiter::isCurrent) + assertEquals(1, winners.size) + assertEquals(winners.single().running, arbiter.isRunningRequested) + + tokens.filterNot(arbiter::isCurrent).forEach { stale -> + assertFalse(arbiter.resetToStopped(stale)) + } + assertTrue(arbiter.isCurrent(winners.single())) + } finally { + executor.shutdownNow() + } + } +} diff --git a/android/tests/service/ServiceConfigTest.kt b/android/tests/service/ServiceConfigTest.kt new file mode 100644 index 0000000000..3db2a7c753 --- /dev/null +++ b/android/tests/service/ServiceConfigTest.kt @@ -0,0 +1,76 @@ +package com.follow.clash.service + +import com.follow.clash.common.AccessControlMode +import com.follow.clash.service.models.AccessControlProps +import com.follow.clash.service.models.NotificationParams +import com.follow.clash.service.models.VpnOptions +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Test + +private fun vpnOptions(port: Int) = VpnOptions( + enable = true, + port = port, + ipv6 = false, + dnsHijacking = true, + accessControlProps = AccessControlProps( + enable = true, + mode = AccessControlMode.REJECT_SELECTED, + acceptList = listOf("com.example.accepted"), + rejectList = listOf("com.example.rejected"), + ), + allowBypass = true, + systemProxy = false, + bypassDomain = listOf("example.test"), + stack = "system", + routeAddress = listOf("0.0.0.0/0"), +) + +class ServiceConfigTest { + @Test + fun `notification params default to the app name and stop label`() { + val defaults = NotificationParams() + + assertEquals("FlClash", defaults.title) + assertEquals("STOP", defaults.stopText) + assertEquals(false, defaults.onlyStatisticsProxy) + } + + @Test + fun `updateVpnOptions publishes the latest options`() { + ServiceConfig.updateVpnOptions(vpnOptions(7890)) + assertEquals(7890, ServiceConfig.vpnOptions?.port) + + val latest = vpnOptions(7891) + ServiceConfig.updateVpnOptions(latest) + + assertSame(latest, ServiceConfig.vpnOptions) + } + + @Test + fun `updateNotificationParams emits through the state flow`() = runTest { + val params = NotificationParams( + title = "Profile", + stopText = "Halt", + onlyStatisticsProxy = true, + ) + + ServiceConfig.updateNotificationParams(params) + + assertSame(params, ServiceConfig.notificationParams.value) + } + + @Test + fun `notification params state flow keeps the newest value`() = runTest { + val first = NotificationParams(title = "first") + val second = NotificationParams(title = "second") + + ServiceConfig.updateNotificationParams(first) + ServiceConfig.updateNotificationParams(second) + + assertSame(second, ServiceConfig.notificationParams.value) + assertNotSame(first, ServiceConfig.notificationParams.value) + } +} diff --git a/android/tests/service/TrafficTest.kt b/android/tests/service/TrafficTest.kt new file mode 100644 index 0000000000..b0de11ea8b --- /dev/null +++ b/android/tests/service/TrafficTest.kt @@ -0,0 +1,67 @@ +package com.follow.clash.service.models + +import java.util.Locale +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TrafficTest { + private lateinit var defaultLocale: Locale + + @Before + fun pinLocale() { + // speedText formats through String.format, which follows the default locale. + defaultLocale = Locale.getDefault() + Locale.setDefault(Locale.ROOT) + } + + @After + fun restoreLocale() { + Locale.setDefault(defaultLocale) + } + + @Test + fun `renders whole bytes without a decimal part`() { + assertEquals("0B/s↑ 0B/s↓", Traffic(up = 0, down = 0).speedText) + assertEquals("1023B/s↑ 1B/s↓", Traffic(up = 1023, down = 1).speedText) + } + + @Test + fun `promotes to the next unit at 1024`() { + assertEquals("1.0KB/s↑ 1.0KB/s↓", Traffic(up = 1024, down = 1024).speedText) + } + + @Test + fun `keeps one decimal place for fractional values`() { + assertEquals("1.5KB/s↑ 2.5KB/s↓", Traffic(up = 1536, down = 2560).speedText) + } + + @Test + fun `scales through every unit`() { + assertTrue(Traffic(up = 1024L * 1024, down = 0).speedText.startsWith("1.0MB")) + assertTrue( + Traffic(up = 1024L * 1024 * 1024, down = 0).speedText.startsWith("1.0GB"), + ) + assertTrue( + Traffic(up = 1024L * 1024 * 1024 * 1024, down = 0).speedText + .startsWith("1.0TB"), + ) + } + + @Test + fun `stops promoting above terabytes`() { + val text = Traffic(up = 1024L * 1024 * 1024 * 1024 * 1024, down = 0).speedText + + assertTrue(text.startsWith("1024.0TB")) + } + + @Test + fun `labels upload before download`() { + val text = Traffic(up = 1, down = 2).speedText + + assertTrue(text.indexOf("↑") < text.indexOf("↓")) + assertEquals("1B/s↑ 2B/s↓", text) + } +} diff --git a/android/tests/service/VpnOptionsTest.kt b/android/tests/service/VpnOptionsTest.kt new file mode 100644 index 0000000000..e3bafd6595 --- /dev/null +++ b/android/tests/service/VpnOptionsTest.kt @@ -0,0 +1,144 @@ +package com.follow.clash.service.models + +import com.follow.clash.common.AccessControlMode +import java.net.Inet4Address +import java.net.Inet6Address +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +private fun optionsWithRoutes(routeAddress: List) = VpnOptions( + enable = true, + port = 7890, + ipv6 = true, + dnsHijacking = false, + accessControlProps = AccessControlProps( + enable = false, + mode = AccessControlMode.ACCEPT_SELECTED, + acceptList = emptyList(), + rejectList = emptyList(), + ), + allowBypass = false, + systemProxy = true, + bypassDomain = emptyList(), + stack = "gvisor", + routeAddress = routeAddress, +) + +class ToCIDRTest { + @Test + fun `parses an IPv4 network`() { + val cidr = "192.168.1.0/24".toCIDR() + + assertTrue(cidr.address is Inet4Address) + assertEquals("192.168.1.0", cidr.address.hostAddress) + assertEquals(24, cidr.prefixLength) + } + + @Test + fun `parses an IPv6 network`() { + val cidr = "fd00::/8".toCIDR() + + assertTrue(cidr.address is Inet6Address) + assertEquals(8, cidr.prefixLength) + } + + @Test + fun `accepts the boundary prefix lengths`() { + assertEquals(0, "0.0.0.0/0".toCIDR().prefixLength) + assertEquals(32, "10.0.0.1/32".toCIDR().prefixLength) + assertEquals(0, "::/0".toCIDR().prefixLength) + assertEquals(128, "fd00::1/128".toCIDR().prefixLength) + } + + @Test + fun `rejects an address without a prefix`() { + val error = assertThrows(IllegalArgumentException::class.java) { + "192.168.1.0".toCIDR() + } + assertTrue(error.message!!.contains("Invalid CIDR format")) + } + + @Test + fun `rejects an address with too many segments`() { + assertThrows(IllegalArgumentException::class.java) { + "192.168.1.0/24/8".toCIDR() + } + } + + @Test + fun `rejects a non-numeric prefix`() { + val error = assertThrows(IllegalArgumentException::class.java) { + "192.168.1.0/abc".toCIDR() + } + assertTrue(error.message!!.contains("Invalid prefix length")) + } + + @Test + fun `rejects an IPv4 prefix above 32`() { + assertThrows(IllegalArgumentException::class.java) { + "192.168.1.0/33".toCIDR() + } + } + + @Test + fun `rejects an IPv6 prefix above 128`() { + assertThrows(IllegalArgumentException::class.java) { + "fd00::/129".toCIDR() + } + } + + @Test + fun `rejects a negative prefix`() { + assertThrows(IllegalArgumentException::class.java) { + "192.168.1.0/-1".toCIDR() + } + } +} + +class RouteAddressTest { + @Test + fun `splits a mixed route list by address family`() { + val options = optionsWithRoutes( + listOf("192.168.1.0/24", "fd00::/8", "10.0.0.0/8", "2000::/3"), + ) + + val ipv4 = options.getIpv4RouteAddress() + val ipv6 = options.getIpv6RouteAddress() + + assertEquals(2, ipv4.size) + assertEquals(2, ipv6.size) + assertTrue(ipv4.all { it.address is Inet4Address }) + assertTrue(ipv6.all { it.address is Inet6Address }) + } + + @Test + fun `returns empty lists for an empty route list`() { + val options = optionsWithRoutes(emptyList()) + + assertTrue(options.getIpv4RouteAddress().isEmpty()) + assertTrue(options.getIpv6RouteAddress().isEmpty()) + } + + @Test + fun `preserves the configured order within a family`() { + val options = optionsWithRoutes( + listOf("10.0.0.0/8", "fd00::/8", "192.168.0.0/16"), + ) + + assertEquals( + listOf(8, 16), + options.getIpv4RouteAddress().map { it.prefixLength }, + ) + } + + @Test + fun `propagates a malformed entry instead of silently dropping it`() { + val options = optionsWithRoutes(listOf("192.168.1.0/24", "not-a-cidr")) + + assertThrows(IllegalArgumentException::class.java) { + options.getIpv4RouteAddress() + } + } +} diff --git a/arb/intl_en.arb b/arb/intl_en.arb index e80f21e0e5..ba333fe7c4 100644 --- a/arb/intl_en.arb +++ b/arb/intl_en.arb @@ -90,8 +90,8 @@ "appAccessControl": "App access control", "accessControlAllowDesc": "Only allow selected app to enter VPN", "accessControlNotAllowDesc": "The selected application will be excluded from VPN", + "accessControlDisabledDesc": "App access control is turned off", "selected": "Selected", - "proxyPort": "ProxyPort", "port": "Port", "logLevel": "LogLevel", "show": "Show", @@ -105,6 +105,8 @@ "stopVpn": "Stopping VPN...", "compatible": "Compatibility mode", "notSelectedTip": "The current proxy group cannot be selected.", + "changeProxyFailedTip": "Failed to switch proxy, the previous selection has been restored", + "databaseWriteFailedTip": "Failed to save the change, it has been rolled back", "tip": "tip", "account": "Account", "backup": "Backup", @@ -339,7 +341,6 @@ "nullTip": "No {label} yet", "script": "Script", "color": "Color", - "rename": "Rename", "unnamed": "Unnamed", "pleaseEnterScriptName": "Please enter a script name", "mixedPort": "Mixed Port", @@ -393,7 +394,6 @@ "confirmForceCrashCore": "Are you sure you want to force crash the core?", "confirmClearAllData": "Are you sure you want to clear all data?", "loading": "Loading...", - "loadTest": "Load test", "yearsAgo": "{count, plural, =1{1 year ago} other{{count} years ago}}", "monthsAgo": "{count, plural, =1{1 month ago} other{{count} months ago}}", "daysAgo": "{count, plural, =1{1 day ago} other{{count} days ago}}", @@ -549,9 +549,34 @@ "hours": "hours", "hoursCount": "{count} hours", "geoResources": "Geo Resources", - "geoUpdating": "Updating {name}...", "geoSkipped": "{name} is already up to date", "geoUpdated": "{name} updated", "secondsCount": "{count} seconds", - "entriesCount": "{count} entries" + "entriesCount": "{count} entries", + "changelogBreaking": "Breaking changes", + "changelogFeatures": "New features", + "changelogFixes": "Bug fixes", + "changelogPerformance": "Performance", + "changelogReverts": "Reverts", + "close": "Close", + "back": "Back", + "minimize": "Minimize", + "maximize": "Maximize", + "unmaximize": "Restore", + "pinWindow": "Pin window", + "unpinWindow": "Unpin window", + "toggleLabel": "Toggle labels", + "torch": "Torch", + "pickFromAlbum": "Pick from album", + "blockConnection": "Block connection", + "closeConnections": "Close connections", + "scrollToSelected": "Scroll to selected", + "showMore": "Show more", + "showLess": "Show less", + "previousMatch": "Previous match", + "nextMatch": "Next match", + "clearSearch": "Clear search", + "addWidget": "Add widget", + "showPassword": "Show password", + "hidePassword": "Hide password" } diff --git a/arb/intl_ja.arb b/arb/intl_ja.arb index 13d41bf4e6..64ec79f9ad 100644 --- a/arb/intl_ja.arb +++ b/arb/intl_ja.arb @@ -90,8 +90,8 @@ "appAccessControl": "アプリアクセス制御", "accessControlAllowDesc": "選択したアプリのみVPNを許可", "accessControlNotAllowDesc": "選択したアプリをVPNから除外", + "accessControlDisabledDesc": "アプリアクセス制御はオフです", "selected": "選択済み", - "proxyPort": "プロキシポート", "port": "ポート", "logLevel": "ログレベル", "show": "表示", @@ -105,6 +105,8 @@ "stopVpn": "VPNを停止中...", "compatible": "互換モード", "notSelectedTip": "現在のプロキシグループは選択できません", + "changeProxyFailedTip": "プロキシの切り替えに失敗しました。前回の選択に戻しました", + "databaseWriteFailedTip": "変更の保存に失敗したため、元に戻しました", "tip": "ヒント", "account": "アカウント", "backup": "バックアップ", @@ -339,7 +341,6 @@ "nullTip": "まだ{label}はありません", "script": "スクリプト", "color": "カラー", - "rename": "リネーム", "unnamed": "無題", "pleaseEnterScriptName": "スクリプト名を入力してください", "mixedPort": "混合ポート", @@ -393,7 +394,6 @@ "confirmForceCrashCore": "コアを強制的にクラッシュさせてもよろしいですか?", "confirmClearAllData": "すべてのデータをクリアしてもよろしいですか?", "loading": "読み込み中...", - "loadTest": "読み込みテスト", "yearsAgo": "{count}年前", "monthsAgo": "{count}ヶ月前", "daysAgo": "{count}日前", @@ -479,7 +479,7 @@ "ruleActionDomainSuffixDesc": "ドメイン接尾辞をマッチング", "ruleActionDomainKeywordDesc": "ドメインキーワードをマッチング", "ruleActionDomainRegexDesc": "ワイルドカードマッチング(*と?のみサポート)", - "ruleActionGeositeDesc": "Match domains within Geosite", + "ruleActionGeositeDesc": "Geosite 内のドメインに一致", "ruleActionIpCidrDesc": "IPアドレス範囲をマッチング", "ruleActionIpCidr6Desc": "IPアドレス範囲をマッチング(IP-CIDR6はエイリアスです)", "ruleActionIpSuffixDesc": "IP接尾辞範囲をマッチング", @@ -522,20 +522,20 @@ "proxyGroupDetectedAbnormal": "現在のプロキシグループが異常であることを検出しました", "proxyProviderDetectedAbnormal": "選択されたプロキシプロバイダーに異常があることを検出しました", "proxyDetectedAbnormal": "選択されたプロキシに異常があることを検出しました", - "createProfile": "Create Profile", - "locationPermissionRequired": "Location Permission Required", - "locationPermissionGuide": "1. Open System Settings > Privacy & Security\n2. Choose Location Services\n3. Find and check {appName} in the right list\n\nAfter completing the setup, return to the app and use it normally. Thank you for your cooperation.", - "prerequisites": "Prerequisites", - "ignoreBatteryOptimization": "Ignore Battery Optimization", - "batteryOptimizationDesc": "To ensure background operation, please disable battery optimization for this app. Tap to go to settings.", + "createProfile": "プロファイルを作成", + "locationPermissionRequired": "位置情報の権限が必要です", + "locationPermissionGuide": "1. システム設定 > プライバシーとセキュリティ を開く\n2. 位置情報サービス を選択\n3. 右側のリストで {appName} を見つけてチェックする\n\n設定が完了したらアプリに戻ると通常どおり使用できます。ご協力ありがとうございます。", + "prerequisites": "前提条件", + "ignoreBatteryOptimization": "バッテリー最適化を無視", + "batteryOptimizationDesc": "バックグラウンド動作を保証するため、このアプリのバッテリー最適化を無効にしてください。タップして設定に移動します。", "batteryOptimizationStatusTip": "システムの影響により、この状態は必ずしも正確とは限りません。", - "locationPermission": "Location Permission", - "locationPermissionDesc": "According to system requirements, obtaining the Wi-Fi name requires you to grant location permission.", - "excludeSsids": "Exclude SSIDs", - "excludeSsidsDesc": "When connected to an excluded SSID Wi-Fi, the app running state will be automatically switched.", - "ssidsEmpty": "SSIDs is empty", - "onDemand": "On Demand", - "onDemandDesc": "Configure the program running state for specific scenarios", + "locationPermission": "位置情報の権限", + "locationPermissionDesc": "システムの要件により、Wi-Fi 名を取得するには位置情報の権限を許可する必要があります。", + "excludeSsids": "SSID を除外", + "excludeSsidsDesc": "除外した SSID の Wi-Fi に接続すると、アプリの動作状態が自動的に切り替わります。", + "ssidsEmpty": "SSID が空です", + "onDemand": "オンデマンド", + "onDemandDesc": "特定のシーンでのアプリの動作状態を設定します", "locationPermissionDeniedMessage": "位置情報の権限が拒否されたため、現在の Wi-Fi 名を取得できません。システム設定で位置情報の権限を手動で有効にしてください。", "addSsid": "SSIDを追加", "editSsid": "SSIDを編集", @@ -549,9 +549,34 @@ "hours": "時間", "hoursCount": "{count} 時間", "geoResources": "Geoリソース", - "geoUpdating": "{name}を更新中...", "geoSkipped": "{name} はすでに最新です", "geoUpdated": "{name} 更新済み", "secondsCount": "{count} 秒", - "entriesCount": "{count} エントリ" + "entriesCount": "{count} エントリ", + "changelogBreaking": "重大な変更", + "changelogFeatures": "新機能", + "changelogFixes": "不具合修正", + "changelogPerformance": "パフォーマンス", + "changelogReverts": "取り消し", + "close": "閉じる", + "back": "戻る", + "minimize": "最小化", + "maximize": "最大化", + "unmaximize": "元のサイズに戻す", + "pinWindow": "ウィンドウを最前面に固定", + "unpinWindow": "最前面固定を解除", + "toggleLabel": "ラベルを切り替え", + "torch": "ライト", + "pickFromAlbum": "アルバムから選択", + "blockConnection": "接続をブロック", + "closeConnections": "接続を閉じる", + "scrollToSelected": "選択項目へスクロール", + "showMore": "展開する", + "showLess": "折りたたむ", + "previousMatch": "前の一致", + "nextMatch": "次の一致", + "clearSearch": "検索をクリア", + "addWidget": "ウィジェットを追加", + "showPassword": "パスワードを表示", + "hidePassword": "パスワードを非表示" } diff --git a/arb/intl_ru.arb b/arb/intl_ru.arb index bcd965a6bb..d0a7fb52ea 100644 --- a/arb/intl_ru.arb +++ b/arb/intl_ru.arb @@ -43,7 +43,7 @@ "silentLaunchDesc": "Запуск в фоновом режиме", "autoRun": "Автозапуск", "autoRunDesc": "Автоматический запуск при открытии приложения", - "logcat": "Logcat", + "logcat": "Журнал системы", "logcatDesc": "Отключение скроет запись логов", "autoCheckUpdate": "Автопроверка обновлений", "autoCheckUpdateDesc": "Автоматически проверять обновления при запуске приложения", @@ -90,8 +90,8 @@ "appAccessControl": "Контроль доступа приложений", "accessControlAllowDesc": "Разрешить только выбранным приложениям доступ к VPN", "accessControlNotAllowDesc": "Выбранные приложения будут исключены из VPN", + "accessControlDisabledDesc": "Контроль доступа приложений отключен", "selected": "Выбрано", - "proxyPort": "Порт прокси", "port": "Порт", "logLevel": "Уровень логов", "show": "Показать", @@ -105,6 +105,8 @@ "stopVpn": "Остановка VPN...", "compatible": "Режим совместимости", "notSelectedTip": "Текущая группа прокси не может быть выбрана.", + "changeProxyFailedTip": "Не удалось переключить прокси, предыдущий выбор восстановлен", + "databaseWriteFailedTip": "Не удалось сохранить изменение, оно было отменено", "tip": "подсказка", "account": "Аккаунт", "backup": "Резервное копирование", @@ -339,7 +341,6 @@ "nullTip": "{label} пока отсутствуют", "script": "Скрипт", "color": "Цвет", - "rename": "Переименовать", "unnamed": "Без имени", "pleaseEnterScriptName": "Пожалуйста, введите название скрипта", "mixedPort": "Смешанный порт", @@ -393,7 +394,6 @@ "confirmForceCrashCore": "Вы уверены, что хотите принудительно аварийно завершить работу ядра?", "confirmClearAllData": "Вы уверены, что хотите очистить все данные?", "loading": "Загрузка...", - "loadTest": "Тест загрузки", "yearsAgo": "{count, plural, one{{count} год назад} few{{count} года назад} many{{count} лет назад} other{{count} года назад}}", "monthsAgo": "{count, plural, one{{count} месяц назад} few{{count} месяца назад} many{{count} месяцев назад} other{{count} месяца назад}}", "daysAgo": "{count, plural, one{{count} день назад} few{{count} дня назад} many{{count} дней назад} other{{count} дня назад}}", @@ -522,20 +522,20 @@ "proxyGroupDetectedAbnormal": "Обнаружена аномалия текущей группы прокси", "proxyProviderDetectedAbnormal": "Обнаружена аномалия выбранных провайдеров прокси", "proxyDetectedAbnormal": "Обнаружена аномалия выбранных прокси", - "createProfile": "Create Profile", - "locationPermissionRequired": "Location Permission Required", - "locationPermissionGuide": "1. Open System Settings > Privacy & Security\n2. Choose Location Services\n3. Find and check {appName} in the right list\n\nAfter completing the setup, return to the app and use it normally. Thank you for your cooperation.", - "prerequisites": "Prerequisites", - "ignoreBatteryOptimization": "Ignore Battery Optimization", - "batteryOptimizationDesc": "To ensure background operation, please disable battery optimization for this app. Tap to go to settings.", + "createProfile": "Создать профиль", + "locationPermissionRequired": "Требуется разрешение на геолокацию", + "locationPermissionGuide": "1. Откройте Системные настройки > Конфиденциальность и безопасность\n2. Выберите Службы геолокации\n3. Найдите и отметьте {appName} в списке справа\n\nПосле настройки вернитесь в приложение и продолжайте работу. Спасибо за сотрудничество.", + "prerequisites": "Предварительные условия", + "ignoreBatteryOptimization": "Игнорировать оптимизацию батареи", + "batteryOptimizationDesc": "Чтобы приложение работало в фоне, отключите для него оптимизацию батареи. Нажмите, чтобы перейти к настройкам.", "batteryOptimizationStatusTip": "Из-за особенностей системы этот статус не всегда может быть точным.", - "locationPermission": "Location Permission", - "locationPermissionDesc": "According to system requirements, obtaining the Wi-Fi name requires you to grant location permission.", - "excludeSsids": "Exclude SSIDs", - "excludeSsidsDesc": "When connected to an excluded SSID Wi-Fi, the app running state will be automatically switched.", - "ssidsEmpty": "SSIDs is empty", - "onDemand": "On Demand", - "onDemandDesc": "Configure the program running state for specific scenarios", + "locationPermission": "Разрешение на геолокацию", + "locationPermissionDesc": "По требованию системы для получения имени сети Wi-Fi необходимо разрешение на геолокацию.", + "excludeSsids": "Исключить SSID", + "excludeSsidsDesc": "При подключении к Wi-Fi с исключённым SSID состояние работы приложения переключается автоматически.", + "ssidsEmpty": "Список SSID пуст", + "onDemand": "По требованию", + "onDemandDesc": "Настройте состояние работы приложения для определённых сценариев", "locationPermissionDeniedMessage": "Разрешение на геолокацию отклонено, поэтому невозможно получить имя текущей Wi-Fi сети. Включите разрешение на геолокацию вручную в системных настройках.", "addSsid": "Добавить SSID", "editSsid": "Изменить SSID", @@ -549,9 +549,34 @@ "hours": "часов", "hoursCount": "{count} часов", "geoResources": "Ресурсы Geo", - "geoUpdating": "Обновление {name}...", "geoSkipped": "Для {name} уже установлена последняя версия", "geoUpdated": "{name} обновлено", "secondsCount": "{count} секунд", - "entriesCount": "{count} записей" + "entriesCount": "{count} записей", + "changelogBreaking": "Важные изменения", + "changelogFeatures": "Новые функции", + "changelogFixes": "Исправления", + "changelogPerformance": "Производительность", + "changelogReverts": "Откаты", + "close": "Закрыть", + "back": "Назад", + "minimize": "Свернуть", + "maximize": "Развернуть", + "unmaximize": "Восстановить", + "pinWindow": "Закрепить окно", + "unpinWindow": "Открепить окно", + "toggleLabel": "Переключить подписи", + "torch": "Фонарик", + "pickFromAlbum": "Выбрать из галереи", + "blockConnection": "Заблокировать соединение", + "closeConnections": "Закрыть соединения", + "scrollToSelected": "Прокрутить к выбранному", + "showMore": "Показать больше", + "showLess": "Показать меньше", + "previousMatch": "Предыдущее совпадение", + "nextMatch": "Следующее совпадение", + "clearSearch": "Очистить поиск", + "addWidget": "Добавить виджет", + "showPassword": "Показать пароль", + "hidePassword": "Скрыть пароль" } diff --git a/arb/intl_zh_CN.arb b/arb/intl_zh_CN.arb index e4e805450a..4f6e90a487 100644 --- a/arb/intl_zh_CN.arb +++ b/arb/intl_zh_CN.arb @@ -90,8 +90,8 @@ "appAccessControl": "应用访问控制", "accessControlAllowDesc": "只允许选中应用进入VPN", "accessControlNotAllowDesc": "选中应用将会被排除在VPN之外", + "accessControlDisabledDesc": "应用访问控制已关闭", "selected": "已选择", - "proxyPort": "代理端口", "port": "端口", "logLevel": "日志等级", "show": "显示", @@ -105,6 +105,8 @@ "stopVpn": "正在停止VPN...", "compatible": "兼容模式", "notSelectedTip": "当前代理组无法选中", + "changeProxyFailedTip": "切换代理失败,已恢复上一次的选择", + "databaseWriteFailedTip": "保存更改失败,已回滚", "tip": "提示", "account": "账号", "backup": "备份", @@ -339,7 +341,6 @@ "nullTip": "暂无{label}", "script": "脚本", "color": "颜色", - "rename": "重命名", "unnamed": "未命名", "pleaseEnterScriptName": "请输入脚本名称", "mixedPort": "混合端口", @@ -393,7 +394,6 @@ "confirmForceCrashCore": "确定要强制崩溃核心?", "confirmClearAllData": "确定要清除所有数据?", "loading": "加载中...", - "loadTest": "加载测试", "yearsAgo": "{count} 年前", "monthsAgo": "{count} 个月前", "daysAgo": "{count} 天前", @@ -549,9 +549,34 @@ "hours": "小时", "hoursCount": "{count} 小时", "geoResources": "Geo 资源", - "geoUpdating": "正在更新 {name}...", "geoSkipped": "{name} 已是最新版本", "geoUpdated": "{name} 已更新", "secondsCount": "{count} 秒", - "entriesCount": "{count} 个条目" + "entriesCount": "{count} 个条目", + "changelogBreaking": "重大变更", + "changelogFeatures": "新功能", + "changelogFixes": "问题修复", + "changelogPerformance": "性能优化", + "changelogReverts": "已回滚", + "close": "关闭", + "back": "返回", + "minimize": "最小化", + "maximize": "最大化", + "unmaximize": "向下还原", + "pinWindow": "窗口置顶", + "unpinWindow": "取消置顶", + "toggleLabel": "切换标签", + "torch": "手电筒", + "pickFromAlbum": "从相册选择", + "blockConnection": "阻止连接", + "closeConnections": "关闭连接", + "scrollToSelected": "滚动到已选", + "showMore": "展开", + "showLess": "收起", + "previousMatch": "上一个匹配", + "nextMatch": "下一个匹配", + "clearSearch": "清除搜索", + "addWidget": "添加组件", + "showPassword": "显示密码", + "hidePassword": "隐藏密码" } diff --git a/changelog.json b/changelog.json new file mode 100644 index 0000000000..bd6a31e550 --- /dev/null +++ b/changelog.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 2, + "versions": [ + { + "version": "0.8.96", + "tag": "v0.8.96", + "date": "2026-08-17", + "prerelease": false, + "groups": [ + { + "type": "fix", + "entries": [ + { + "id": "7fb4f4f", + "text": "Fix whole group delay test failing on Windows" + } + ] + }, + { + "type": "perf", + "entries": [ + { + "id": "903e2b8", + "text": "Optimize package icon loading and connections polling" + } + ] + } + ] + } + ] +} diff --git a/core/Clash.Meta b/core/Clash.Meta index 0f7f05adff..7dbfedf1b5 160000 --- a/core/Clash.Meta +++ b/core/Clash.Meta @@ -1 +1 @@ -Subproject commit 0f7f05adff5e2c49775a112dcfe05a6aa36fda0c +Subproject commit 7dbfedf1b5839eff87bb37c6b6d2ccfd5d71ca51 diff --git a/core/bride.c b/core/bride.c index b432d29a95..6563909b43 100644 --- a/core/bride.c +++ b/core/bride.c @@ -1,3 +1,5 @@ +//go:build android && cgo + #include "bride.h" void (*release_object_func)(void *obj); @@ -11,21 +13,36 @@ char* (*resolve_process_func)(void *tun_interface,int protocol, const char *sour void (*result_func)(void *invoke_Interface, const char *data); void protect(void *tun_interface, int fd) { + if (protect_func == NULL) { + return; + } protect_func(tun_interface, fd); } char* resolve_process(void *tun_interface, int protocol, const char *source, const char *target, int uid) { + if (resolve_process_func == NULL) { + return NULL; + } return resolve_process_func(tun_interface, protocol, source, target, uid); } void release_object(void *obj) { + if (release_object_func == NULL) { + return; + } release_object_func(obj); } void free_string(char *data) { + if (free_string_func == NULL) { + return; + } free_string_func(data); } void result(void *invoke_Interface, const char *data) { - return result_func(invoke_Interface, data); -} \ No newline at end of file + if (result_func == NULL) { + return; + } + result_func(invoke_Interface, data); +} diff --git a/core/common.go b/core/common.go index 13717488d2..74b0320be0 100644 --- a/core/common.go +++ b/core/common.go @@ -10,12 +10,13 @@ import ( "path/filepath" "runtime" "sync" + "sync/atomic" + "time" "github.com/metacubex/mihomo/adapter" "github.com/metacubex/mihomo/adapter/inbound" "github.com/metacubex/mihomo/adapter/outboundgroup" "github.com/metacubex/mihomo/adapter/provider" - "github.com/metacubex/mihomo/common/batch" "github.com/metacubex/mihomo/component/dialer" "github.com/metacubex/mihomo/component/resolver" "github.com/metacubex/mihomo/component/updater" @@ -27,28 +28,53 @@ import ( "github.com/metacubex/mihomo/hub/executor" "github.com/metacubex/mihomo/hub/route" "github.com/metacubex/mihomo/listener" + LC "github.com/metacubex/mihomo/listener/config" "github.com/metacubex/mihomo/log" rp "github.com/metacubex/mihomo/rules/provider" "github.com/metacubex/mihomo/tunnel" ) +const ( + delayTestConcurrency = 50 + defaultTestURL = "https://www.gstatic.com/generate_204" + defaultDelayTestTimeout = 5 * time.Second +) + var ( + configMu sync.Mutex currentConfig *config.Config - version = 0 - isRunning = false - runLock sync.Mutex - mBatch, _ = batch.New[bool](context.Background(), batch.WithConcurrencyNum[bool](50)) - debugError = false + + // selectMu serialises proxy-group selection writes. mihomo's Selector.Set + // writes s.selected with no lock of its own, so something has to; configMu + // used to, which made a tap on a node wait out a whole config apply, + // provider downloads included, for a write that touches one field. + // patchSelectGroup takes it under configMu, fixing the order as + // configMu -> selectMu. + selectMu sync.Mutex + + isInit atomic.Bool + isRunning atomic.Bool + sdkVersion atomic.Int32 + testURL atomic.Pointer[string] + + delayTestSlots = make(chan struct{}, delayTestConcurrency) + + debugStderr = os.Getenv("FLCLASH_CORE_DEBUG") != "" ) -func getExternalProvidersRaw() map[string]cp.Provider { +var ( + errConfigNotApplied = errors.New("config is not applied") + errNotExternalProvider = errors.New("not external provider") +) + +func externalProviders() map[string]cp.Provider { eps := make(map[string]cp.Provider) - for n, p := range tunnel.Providers() { + for n, p := range tunnel.ProvidersSnapshot() { if p.VehicleType() != cp.Compatible { eps[n] = p } } - for n, p := range tunnel.RuleProviders() { + for n, p := range tunnel.RuleProvidersSnapshot() { if p.VehicleType() != cp.Compatible { eps[n] = p } @@ -56,74 +82,68 @@ func getExternalProvidersRaw() map[string]cp.Provider { return eps } +func lookupExternalProvider(name string) (cp.Provider, bool) { + if p, exist := tunnel.RuleProvidersSnapshot()[name]; exist && p.VehicleType() != cp.Compatible { + return p, true + } + if p, exist := tunnel.ProvidersSnapshot()[name]; exist && p.VehicleType() != cp.Compatible { + return p, true + } + return nil, false +} + func toExternalProvider(p cp.Provider) (*ExternalProvider, error) { - switch p.(type) { + switch typed := p.(type) { case *provider.ProxySetProvider: - psp := p.(*provider.ProxySetProvider) return &ExternalProvider{ - Name: psp.Name(), - Type: psp.Type().String(), - VehicleType: psp.VehicleType().String(), - Count: psp.Count(), - UpdateAt: psp.UpdatedAt(), - Path: psp.Vehicle().Path(), - SubscriptionInfo: psp.GetSubscriptionInfo(), + Name: typed.Name(), + Type: typed.Type().String(), + VehicleType: typed.VehicleType().String(), + Count: typed.Count(), + UpdateAt: typed.UpdatedAt(), + Path: typed.Vehicle().Path(), + SubscriptionInfo: typed.GetSubscriptionInfo(), }, nil case *rp.RuleSetProvider: - rsp := p.(*rp.RuleSetProvider) return &ExternalProvider{ - Name: rsp.Name(), - Type: rsp.Type().String(), - VehicleType: rsp.VehicleType().String(), - Count: rsp.Count(), - UpdateAt: rsp.UpdatedAt(), - Path: rsp.Vehicle().Path(), + Name: typed.Name(), + Type: typed.Type().String(), + VehicleType: typed.VehicleType().String(), + Count: typed.Count(), + UpdateAt: typed.UpdatedAt(), + Path: typed.Vehicle().Path(), }, nil default: - return nil, errors.New("not external provider") + return nil, errNotExternalProvider } } -func sideUpdateExternalProvider(p cp.Provider, bytes []byte) error { - switch p.(type) { +func sideUpdateExternalProvider(p cp.Provider, data []byte) error { + switch typed := p.(type) { case *provider.ProxySetProvider: - psp := p.(*provider.ProxySetProvider) - _, _, err := psp.SideUpdate(bytes) - if err == nil { - return err - } - return nil - case rp.RuleSetProvider: - rsp := p.(*rp.RuleSetProvider) - _, _, err := rsp.SideUpdate(bytes) - if err == nil { - return err - } - return nil + _, _, err := typed.SideUpdate(data) + return err + case *rp.RuleSetProvider: + _, _, err := typed.SideUpdate(data) + return err default: - return errors.New("not external provider") + return errNotExternalProvider } } -func updateListeners() { - if !isRunning { +func updateListeners(cfg *config.Config) { + if cfg == nil || !isRunning.Load() { return } - if currentConfig == nil { - return - } - listeners := currentConfig.Listeners - general := currentConfig.General - listener.PatchInboundListeners(listeners, tunnel.Tunnel, true) + general := cfg.General + listener.PatchInboundListeners(cfg.Listeners, tunnel.Tunnel, true) - allowLan := general.AllowLan - listener.SetAllowLan(allowLan) + listener.SetAllowLan(general.AllowLan) inbound.SetSkipAuthPrefixes(general.SkipAuthPrefixes) inbound.SetAllowedIPs(general.LanAllowedIPs) inbound.SetDisAllowedIPs(general.LanDisAllowedIPs) - bindAddress := general.BindAddress - listener.SetBindAddress(bindAddress) + listener.SetBindAddress(general.BindAddress) listener.ReCreateHTTP(general.Port, tunnel.Tunnel) listener.ReCreateSocks(general.SocksPort, tunnel.Tunnel) listener.ReCreateRedir(general.RedirPort, tunnel.Tunnel) @@ -137,11 +157,9 @@ func updateListeners() { } } -func stopListeners() { - listener.StopListener() -} - func patchSelectGroup(mapping map[string]string) { + selectMu.Lock() + defer selectMu.Unlock() for name, proxy := range tunnel.AllProxies() { outbound, ok := proxy.(*adapter.Proxy) if !ok { @@ -164,33 +182,97 @@ func patchSelectGroup(mapping map[string]string) { func defaultSetupParams() *SetupParams { return &SetupParams{ - TestURL: "https://www.gstatic.com/generate_204", + TestURL: defaultTestURL, SelectedMap: map[string]string{}, } } -func readFile(path string) ([]byte, error) { - if _, err := os.Stat(path); os.IsNotExist(err) { - return nil, err +func setTestURL(url string) { + if url == "" { + return } - data, err := os.ReadFile(path) - if err != nil { - return nil, err + constant.DefaultTestURL = url + testURL.Store(&url) +} + +func currentTestURL() string { + if url := testURL.Load(); url != nil && *url != "" { + return *url + } + return defaultTestURL +} + +func acquireDelayTestSlot(ctx context.Context) bool { + select { + case delayTestSlots <- struct{}{}: + return true + case <-ctx.Done(): + return false + } +} + +func releaseDelayTestSlot() { + <-delayTestSlots +} + +func routeConfig(cfg *config.Config) *route.Config { + controller := cfg.Controller + routeCfg := &route.Config{ + Addr: controller.ExternalController, + TLSAddr: controller.ExternalControllerTLS, + UnixAddr: controller.ExternalControllerUnix, + PipeAddr: controller.ExternalControllerPipe, + RoutingMark: controller.ExternalControllerRoutingMark, + Secret: controller.Secret, + DohServer: controller.ExternalDohServer, + IsDebug: cfg.General.LogLevel == log.DEBUG, + Cors: route.Cors{ + AllowOrigins: controller.Cors.AllowOrigins, + AllowPrivateNetwork: controller.Cors.AllowPrivateNetwork, + }, + } + if cfg.TLS != nil { + routeCfg.Certificate = cfg.TLS.Certificate + routeCfg.PrivateKey = cfg.TLS.PrivateKey + routeCfg.ClientAuthType = cfg.TLS.ClientAuthType + routeCfg.ClientAuthCert = cfg.TLS.ClientAuthCert + routeCfg.EchKey = cfg.TLS.EchKey } + return routeCfg +} - return data, err +func patchTun(target *LC.Tun, params *tunSchema) { + target.Enable = params.Enable + if params.AutoRoute != nil { + target.AutoRoute = *params.AutoRoute + } + if params.Device != nil { + target.Device = *params.Device + } + if params.RouteAddress != nil { + target.RouteAddress = *params.RouteAddress + } + if params.DNSHijack != nil { + target.DNSHijack = *params.DNSHijack + } + if params.Stack != nil { + target.Stack = *params.Stack + } } -func updateConfig(params *UpdateParams) { - runLock.Lock() - defer runLock.Unlock() +func updateConfig(params *UpdateParams) error { + configMu.Lock() + defer configMu.Unlock() + if currentConfig == nil { + return errConfigNotApplied + } + general := currentConfig.General if params.MixedPort != nil { general.MixedPort = *params.MixedPort } - if params.Sniffing != nil { - general.Sniffing = *params.Sniffing - tunnel.SetSniffing(general.Sniffing) + if params.AllowLan != nil { + general.AllowLan = *params.AllowLan } if params.FindProcessMode != nil { general.FindProcessMode = *params.FindProcessMode @@ -200,10 +282,6 @@ func updateConfig(params *UpdateParams) { general.TCPConcurrent = *params.TCPConcurrent dialer.SetTcpConcurrent(general.TCPConcurrent) } - if params.Interface != nil { - general.Interface = *params.Interface - dialer.DefaultInterface.Store(general.Interface) - } if params.UnifiedDelay != nil { general.UnifiedDelay = *params.UnifiedDelay adapter.UnifiedDelay.Store(general.UnifiedDelay) @@ -220,74 +298,89 @@ func updateConfig(params *UpdateParams) { general.IPv6 = *params.IPv6 resolver.DisableIPv6 = !general.IPv6 } - if params.ExternalController != nil { + if params.Tun != nil { + patchTun(&general.Tun, params.Tun) + } + if params.ExternalController != nil && + *params.ExternalController != currentConfig.Controller.ExternalController { currentConfig.Controller.ExternalController = *params.ExternalController - route.ReCreateServer(&route.Config{ - Addr: currentConfig.Controller.ExternalController, - }) + route.ReCreateServer(routeConfig(currentConfig)) } - if params.Tun != nil { - general.Tun.Enable = params.Tun.Enable - if params.Tun.AutoRoute != nil { - general.Tun.AutoRoute = *params.Tun.AutoRoute - } - if params.Tun.Device != nil { - general.Tun.Device = *params.Tun.Device - } - if params.Tun.RouteAddress != nil { - general.Tun.RouteAddress = *params.Tun.RouteAddress - } - if params.Tun.DNSHijack != nil { - general.Tun.DNSHijack = *params.Tun.DNSHijack - } - if params.Tun.Stack != nil { - general.Tun.Stack = *params.Tun.Stack - } - } + updateListeners(currentConfig) + syncGeoUpdater(params.GeoAutoUpdate, params.GeoUpdateInterval) + return nil +} - if params.GeoAutoUpdate != nil { - updater.SetGeoAutoUpdate(*params.GeoAutoUpdate) +func syncGeoUpdater(autoUpdate *bool, interval *int) { + changed := false + if autoUpdate != nil && *autoUpdate != updater.GeoAutoUpdate() { + updater.SetGeoAutoUpdate(*autoUpdate) + changed = true } - if params.GeoUpdateInterval != nil { - updater.SetGeoUpdateInterval(*params.GeoUpdateInterval) + if interval != nil && *interval != updater.GeoUpdateInterval() { + updater.SetGeoUpdateInterval(*interval) + changed = true } + if !changed { + return + } + reconcileGeoUpdater() +} + +var ( + registerGeoUpdater = updater.RegisterGeoUpdaterWithCancel + stopGeoUpdater = updater.StopGeoUpdater +) - updateListeners() +func reconcileGeoUpdater() { if updater.GeoAutoUpdate() { - updater.RegisterGeoUpdaterWithCancel() + registerGeoUpdater() + return } + stopGeoUpdater() +} + +func loadConfig(path string) (*config.Config, error) { + buf, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return executor.ParseWithBytes(buf) } func applyConfig(params *SetupParams) error { runtime.GC() - runLock.Lock() - defer runLock.Unlock() - var err error - constant.DefaultTestURL = params.TestURL - currentConfig, err = executor.ParseWithPath(filepath.Join(constant.Path.HomeDir(), "config.yaml")) + configMu.Lock() + defer configMu.Unlock() + + setTestURL(params.TestURL) + cfg, err := loadConfig(filepath.Join(constant.Path.HomeDir(), "config.yaml")) if err != nil { - currentConfig, _ = config.ParseRawConfig(config.DefaultRawConfig()) + fallback, fallbackErr := config.ParseRawConfig(config.DefaultRawConfig()) + if fallbackErr != nil { + return err + } + cfg = fallback } - hub.ApplyConfig(currentConfig) + + currentConfig = cfg + hub.ApplyConfig(cfg) patchSelectGroup(params.SelectedMap) - updateListeners() - if updater.GeoAutoUpdate() { - updater.RegisterGeoUpdaterWithCancel() - } + updateListeners(cfg) + reconcileGeoUpdater() return err } func UnmarshalJson(data []byte, v any) error { decoder := json.NewDecoder(b.NewReader(data)) decoder.UseNumber() - err := decoder.Decode(v) - return err + return decoder.Decode(v) } -func logError(format string, args ...interface{}) { +func logError(format string, args ...any) { log.Errorln(format, args...) - if debugError { + if debugStderr { fmt.Fprintf(os.Stderr, "[ERROR] "+format+"\n", args...) } } diff --git a/core/common_test.go b/core/common_test.go new file mode 100644 index 0000000000..f44bbdc9aa --- /dev/null +++ b/core/common_test.go @@ -0,0 +1,341 @@ +package main + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "strconv" + "testing" + + "github.com/metacubex/mihomo/common/utils" + "github.com/metacubex/mihomo/constant" + cp "github.com/metacubex/mihomo/constant/provider" + "github.com/metacubex/mihomo/tunnel" +) + +func TestDefaultSetupParams(t *testing.T) { + params := defaultSetupParams() + + if params.TestURL != "https://www.gstatic.com/generate_204" { + t.Errorf("TestURL = %s, want the gstatic generate_204 probe", params.TestURL) + } + if params.SelectedMap == nil { + t.Error("SelectedMap must be a usable map so decoding can merge into it") + } +} + +func TestDefaultSetupParamsSurvivesPartialDecode(t *testing.T) { + params := defaultSetupParams() + + if err := json.Unmarshal([]byte(`{"selected-map":{"GLOBAL":"auto"}}`), params); err != nil { + t.Fatalf("decode error: %v", err) + } + + if params.TestURL != "https://www.gstatic.com/generate_204" { + t.Errorf("TestURL = %s, want the default to survive a partial payload", params.TestURL) + } + if params.SelectedMap["GLOBAL"] != "auto" { + t.Errorf("SelectedMap = %v, want GLOBAL mapped to auto", params.SelectedMap) + } +} + +func TestUnmarshalJsonPreservesLargeIntegers(t *testing.T) { + target := map[string]any{} + + if err := UnmarshalJson([]byte(`{"id":9007199254740993}`), &target); err != nil { + t.Fatalf("UnmarshalJson error: %v", err) + } + + number, ok := target["id"].(json.Number) + if !ok { + t.Fatalf("id decoded as %T, want json.Number so int64 precision survives", target["id"]) + } + value, err := number.Int64() + if err != nil { + t.Fatalf("Int64() error: %v", err) + } + if value != 9007199254740993 { + t.Errorf("id = %d, want 9007199254740993", value) + } +} + +func TestUnmarshalJsonReportsInvalidPayload(t *testing.T) { + target := map[string]any{} + + if err := UnmarshalJson([]byte(`{`), &target); err == nil { + t.Fatal("UnmarshalJson accepted malformed JSON") + } +} + +func TestToExternalProviderRejectsUnsupportedProvider(t *testing.T) { + provider, err := toExternalProvider(nil) + + if err == nil { + t.Fatal("toExternalProvider accepted a provider it cannot describe") + } + if provider != nil { + t.Errorf("provider = %+v, want nil alongside the error", provider) + } +} + +func TestMethodCallDecodeArgumentsRejectsEmptyPayload(t *testing.T) { + tests := map[string]json.RawMessage{ + "empty": nil, + "null": json.RawMessage("null"), + } + + for name, arguments := range tests { + t.Run(name, func(t *testing.T) { + call := MethodCall{Method: validateConfigMethod, Arguments: arguments} + target := "" + + err := call.decodeArguments(&target) + + if err == nil { + t.Fatal("decodeArguments accepted a missing payload") + } + if err.Error() != "missing arguments" { + t.Errorf("error = %v, want \"missing arguments\"", err) + } + }) + } +} + +func TestMethodCallDecodeArgumentsAcceptsScalar(t *testing.T) { + call := MethodCall{ + Method: validateConfigMethod, + Arguments: json.RawMessage(`"/tmp/config.yaml"`), + } + target := "" + + if err := call.decodeArguments(&target); err != nil { + t.Fatalf("decodeArguments error: %v", err) + } + if target != "/tmp/config.yaml" { + t.Errorf("target = %s, want /tmp/config.yaml", target) + } +} + +func TestSideLoadParamsMatchesTheWirePayload(t *testing.T) { + params := SideLoadParams{} + + if err := json.Unmarshal([]byte(`{"providerName":"rules","data":"payload"}`), ¶ms); err != nil { + t.Fatalf("decode error: %v", err) + } + + if params.ProviderName != "rules" || params.Data != "payload" { + t.Errorf("params = %+v, want {rules payload}", params) + } +} + +func TestSideUpdateExternalProviderRejectsUnsupportedProvider(t *testing.T) { + if err := sideUpdateExternalProvider(nil, []byte("payload")); err == nil { + t.Fatal("sideUpdateExternalProvider accepted a provider it cannot side-load") + } +} + +func coreMethodConstants(t *testing.T) []CoreMethod { + t.Helper() + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, "constant.go", nil, 0) + if err != nil { + t.Fatalf("parse constant.go: %v", err) + } + + var methods []CoreMethod + ast.Inspect(file, func(node ast.Node) bool { + spec, ok := node.(*ast.ValueSpec) + if !ok { + return true + } + typeName, ok := spec.Type.(*ast.Ident) + if !ok || typeName.Name != "CoreMethod" { + return true + } + for _, value := range spec.Values { + literal, ok := value.(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + continue + } + unquoted, err := strconv.Unquote(literal.Value) + if err != nil { + t.Fatalf("unquote %s: %v", literal.Value, err) + } + methods = append(methods, CoreMethod(unquoted)) + } + return true + }) + + if len(methods) == 0 { + t.Fatal("found no CoreMethod constants; the parser lost track of constant.go") + } + return methods +} + +func TestEveryCoreMethodConstantIsDispatchable(t *testing.T) { + notDispatched := map[CoreMethod]string{ + messageMethod: "core-to-host event envelope, never received", + crashMethod: "handled ahead of the table so it bypasses panic recovery", + updateDnsMethod: "registered by the cgo build only", + } + + for _, method := range coreMethodConstants(t) { + if reason, expected := notDispatched[method]; expected { + if _, exists := methodHandlers[method]; exists { + t.Errorf("method %s has a handler but is documented as %s", method, reason) + } + continue + } + if _, exists := methodHandlers[method]; !exists { + t.Errorf("method %s has no handler and would answer not_implemented", method) + } + } +} + +func TestRegisterMethodRejectsADuplicate(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("registerMethod accepted a second handler for an already routed method") + } + }() + + registerMethod(getProxiesMethod, withoutArguments(func(response MethodResponse) {})) +} + +// fakeProxyProvider and fakeRuleProvider stand in for the mihomo providers the +// tunnel holds. Only the name and the vehicle type matter to the lookup; the +// rest of each interface is here because the tunnel maps are typed. +type fakeProxyProvider struct { + name string + vehicle cp.VehicleType +} + +func (f *fakeProxyProvider) Name() string { return f.name } +func (f *fakeProxyProvider) VehicleType() cp.VehicleType { return f.vehicle } +func (f *fakeProxyProvider) Type() cp.ProviderType { return cp.Proxy } +func (f *fakeProxyProvider) Initial() error { return nil } +func (f *fakeProxyProvider) Update() error { return nil } +func (f *fakeProxyProvider) Proxies() []constant.Proxy { return nil } +func (f *fakeProxyProvider) Count() int { return 0 } +func (f *fakeProxyProvider) Touch() {} +func (f *fakeProxyProvider) HealthCheck() {} +func (f *fakeProxyProvider) Version() uint32 { return 0 } +func (f *fakeProxyProvider) RegisterHealthCheckTask(string, utils.IntRanges[uint16], string, uint) { +} +func (f *fakeProxyProvider) HealthCheckURL() string { return "" } + +type fakeRuleProvider struct { + name string + vehicle cp.VehicleType +} + +func (f *fakeRuleProvider) Name() string { return f.name } +func (f *fakeRuleProvider) VehicleType() cp.VehicleType { return f.vehicle } +func (f *fakeRuleProvider) Type() cp.ProviderType { return cp.Rule } +func (f *fakeRuleProvider) Initial() error { return nil } +func (f *fakeRuleProvider) Update() error { return nil } +func (f *fakeRuleProvider) Behavior() cp.RuleBehavior { return cp.Domain } +func (f *fakeRuleProvider) Count() int { return 0 } +func (f *fakeRuleProvider) Match(*constant.Metadata, constant.RuleMatchHelper) bool { + return false +} +func (f *fakeRuleProvider) Strategy() any { return nil } + +// withTunnelProviders installs a provider set for the duration of a test. The +// unit tests never apply a config, so the tunnel starts empty and restoring it +// to empty is restoring what was there. +func withTunnelProviders( + t *testing.T, + proxyProviders map[string]cp.ProxyProvider, + ruleProviders map[string]cp.RuleProvider, +) { + t.Helper() + tunnel.UpdateProxies(nil, proxyProviders) + tunnel.UpdateRules(nil, nil, ruleProviders) + t.Cleanup(func() { + tunnel.UpdateProxies(nil, nil) + tunnel.UpdateRules(nil, nil, nil) + }) +} + +func TestExternalProvidersSkipsInlineProviders(t *testing.T) { + withTunnelProviders(t, + map[string]cp.ProxyProvider{ + "subscription": &fakeProxyProvider{name: "subscription", vehicle: cp.HTTP}, + "inline": &fakeProxyProvider{name: "inline", vehicle: cp.Compatible}, + }, + map[string]cp.RuleProvider{ + "ruleset": &fakeRuleProvider{name: "ruleset", vehicle: cp.HTTP}, + }, + ) + + providers := externalProviders() + + if _, exist := providers["inline"]; exist { + t.Error("a compatible provider was reported as externally updatable") + } + if len(providers) != 2 { + t.Errorf("externalProviders() = %d entries, want subscription and ruleset", len(providers)) + } +} + +func TestLookupExternalProviderFollowsAConfigReplacement(t *testing.T) { + tunnel.UpdateProxies(nil, map[string]cp.ProxyProvider{ + "first": &fakeProxyProvider{name: "first", vehicle: cp.HTTP}, + }) + t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) + + if _, exist := lookupExternalProvider("first"); !exist { + t.Fatal("the installed provider was not found") + } + + tunnel.UpdateProxies(nil, map[string]cp.ProxyProvider{ + "second": &fakeProxyProvider{name: "second", vehicle: cp.HTTP}, + }) + + if _, exist := lookupExternalProvider("first"); exist { + t.Error("a provider the tunnel no longer holds is still reachable, so updating it writes to disk for nothing") + } + if _, exist := lookupExternalProvider("second"); !exist { + t.Error("the replacement provider is not reachable") + } +} + +func TestLookupExternalProviderRejectsInlineAndUnknownNames(t *testing.T) { + withTunnelProviders(t, + map[string]cp.ProxyProvider{ + "inline": &fakeProxyProvider{name: "inline", vehicle: cp.Compatible}, + }, + nil, + ) + + if _, exist := lookupExternalProvider("inline"); exist { + t.Error("an inline provider is not externally updatable but was handed out") + } + if _, exist := lookupExternalProvider("missing"); exist { + t.Error("an unknown name resolved to a provider") + } +} + +// externalProviders writes rule providers last, so one shadows a proxy provider +// sharing its name. The lookup has to agree with the list the host was given. +func TestLookupExternalProviderMatchesTheListedPrecedence(t *testing.T) { + const shared = "same-name" + ruleProvider := &fakeRuleProvider{name: shared, vehicle: cp.HTTP} + withTunnelProviders(t, + map[string]cp.ProxyProvider{shared: &fakeProxyProvider{name: shared, vehicle: cp.HTTP}}, + map[string]cp.RuleProvider{shared: ruleProvider}, + ) + + found, exist := lookupExternalProvider(shared) + if !exist { + t.Fatal("the shared name resolved to nothing") + } + if found != externalProviders()[shared] { + t.Error("lookupExternalProvider disagrees with the list externalProviders builds") + } + if found != cp.Provider(ruleProvider) { + t.Error("the proxy provider shadowed the rule provider, reversing the listed precedence") + } +} diff --git a/core/config_test.go b/core/config_test.go new file mode 100644 index 0000000000..7fe1b0b255 --- /dev/null +++ b/core/config_test.go @@ -0,0 +1,718 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/metacubex/mihomo/component/updater" + "github.com/metacubex/mihomo/config" + "github.com/metacubex/mihomo/constant" + "github.com/metacubex/mihomo/log" + "github.com/metacubex/mihomo/tunnel" +) + +func withCurrentConfig(t *testing.T, cfg *config.Config) { + t.Helper() + previous := currentConfig + currentConfig = cfg + t.Cleanup(func() { currentConfig = previous }) +} + +func TestRouteConfigCarriesControllerCredentials(t *testing.T) { + cfg := &config.Config{ + General: &config.General{LogLevel: log.DEBUG}, + Controller: &config.Controller{ + ExternalController: "127.0.0.1:9090", + ExternalControllerTLS: "127.0.0.1:9443", + ExternalControllerUnix: "/tmp/flclash.sock", + ExternalControllerPipe: `\\.\pipe\flclash`, + ExternalControllerRoutingMark: 1234, + ExternalDohServer: "/dns-query", + Secret: "s3cret", + Cors: config.Cors{ + AllowOrigins: []string{"https://example.test"}, + AllowPrivateNetwork: true, + }, + }, + TLS: &config.TLS{ + Certificate: "cert", + PrivateKey: "key", + ClientAuthType: "require", + ClientAuthCert: "clientCert", + EchKey: "ech", + }, + } + + got := routeConfig(cfg) + + if got.Secret != "s3cret" { + t.Errorf("Secret = %q, want it preserved; an empty secret unauthenticates the controller", got.Secret) + } + if got.Addr != "127.0.0.1:9090" || got.TLSAddr != "127.0.0.1:9443" { + t.Errorf("addresses = %q/%q, want the controller's own listen addresses", got.Addr, got.TLSAddr) + } + if got.UnixAddr != "/tmp/flclash.sock" || got.PipeAddr != `\\.\pipe\flclash` { + t.Errorf("local addresses = %q/%q, want them preserved", got.UnixAddr, got.PipeAddr) + } + if got.RoutingMark != 1234 || got.DohServer != "/dns-query" { + t.Errorf("routingMark/dohServer = %d/%q, want 1234//dns-query", got.RoutingMark, got.DohServer) + } + if got.Certificate != "cert" || got.PrivateKey != "key" || + got.ClientAuthType != "require" || got.ClientAuthCert != "clientCert" || got.EchKey != "ech" { + t.Errorf("TLS material = %+v, want it carried over from cfg.TLS", got) + } + if len(got.Cors.AllowOrigins) != 1 || got.Cors.AllowOrigins[0] != "https://example.test" || + !got.Cors.AllowPrivateNetwork { + t.Errorf("Cors = %+v, want the configured origins", got.Cors) + } + if !got.IsDebug { + t.Error("IsDebug = false, want it derived from a DEBUG log level") + } +} + +func TestRouteConfigToleratesMissingTLSSection(t *testing.T) { + cfg := &config.Config{ + General: &config.General{}, + Controller: &config.Controller{ExternalController: "127.0.0.1:9090"}, + } + + got := routeConfig(cfg) + + if got.Certificate != "" || got.PrivateKey != "" { + t.Errorf("TLS material = %q/%q, want empty when cfg.TLS is absent", got.Certificate, got.PrivateKey) + } +} + +func TestUpdateConfigRejectsAnUnappliedConfig(t *testing.T) { + withCurrentConfig(t, nil) + + if err := updateConfig(&UpdateParams{}); err != errConfigNotApplied { + t.Errorf("updateConfig error = %v, want errConfigNotApplied", err) + } +} + +func TestHandleUpdateConfigReportsAnUnappliedConfig(t *testing.T) { + withCurrentConfig(t, nil) + + if message := handleUpdateConfig(&UpdateParams{}); message == "" { + t.Error("handleUpdateConfig reported success without an applied config") + } +} + +func TestUpdateConfigAppliesAllowLan(t *testing.T) { + withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}}) + allowLan := true + + if err := updateConfig(&UpdateParams{AllowLan: &allowLan}); err != nil { + t.Fatalf("updateConfig error: %v", err) + } + + if !currentConfig.General.AllowLan { + t.Error("AllowLan stayed false; the patched value never reached the general config") + } +} + +func TestUpdateConfigPatchesOnlyTheTunFieldsItWasGiven(t *testing.T) { + withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}}) + currentConfig.General.Tun.Device = "keep-me" + device := "flclash-tun" + + if err := updateConfig(&UpdateParams{Tun: &tunSchema{Enable: true}}); err != nil { + t.Fatalf("updateConfig error: %v", err) + } + if !currentConfig.General.Tun.Enable { + t.Error("Tun.Enable stayed false") + } + if currentConfig.General.Tun.Device != "keep-me" { + t.Errorf("Tun.Device = %q, want an omitted field left untouched", currentConfig.General.Tun.Device) + } + + if err := updateConfig(&UpdateParams{Tun: &tunSchema{Enable: true, Device: &device}}); err != nil { + t.Fatalf("updateConfig error: %v", err) + } + if currentConfig.General.Tun.Device != device { + t.Errorf("Tun.Device = %q, want %q", currentConfig.General.Tun.Device, device) + } +} + +func TestUpdateConfigLeavesTheControllerAloneWhenTheAddressIsUnchanged(t *testing.T) { + withCurrentConfig(t, &config.Config{ + General: &config.General{}, + Controller: &config.Controller{ExternalController: "127.0.0.1:9090", Secret: "s3cret"}, + }) + address := "127.0.0.1:9090" + + if err := updateConfig(&UpdateParams{ExternalController: &address}); err != nil { + t.Fatalf("updateConfig error: %v", err) + } + + if currentConfig.Controller.Secret != "s3cret" { + t.Errorf("Secret = %q, want the controller left untouched", currentConfig.Controller.Secret) + } +} + +// geoUpdaterCalls counts the reconciliation the code under test asked for. The +// real calls spawn a goroutine that downloads the GEO databases, which is not +// something a unit test should reach for. +type geoUpdaterCalls struct { + registered int + stopped int +} + +func stubGeoUpdater(t *testing.T) *geoUpdaterCalls { + t.Helper() + calls := &geoUpdaterCalls{} + previousRegister, previousStop := registerGeoUpdater, stopGeoUpdater + previousAuto, previousInterval := updater.GeoAutoUpdate(), updater.GeoUpdateInterval() + registerGeoUpdater = func() { calls.registered++ } + stopGeoUpdater = func() { calls.stopped++ } + t.Cleanup(func() { + registerGeoUpdater, stopGeoUpdater = previousRegister, previousStop + updater.SetGeoAutoUpdate(previousAuto) + updater.SetGeoUpdateInterval(previousInterval) + }) + return calls +} + +func TestSyncGeoUpdaterStopsTheUpdaterWhenDisabled(t *testing.T) { + calls := stubGeoUpdater(t) + + updater.SetGeoAutoUpdate(true) + updater.SetGeoUpdateInterval(24) + disabled := false + + syncGeoUpdater(&disabled, nil) + + if updater.GeoAutoUpdate() { + t.Error("GeoAutoUpdate stayed on after the setting was turned off") + } + if updater.GeoUpdateInterval() != 24 { + t.Errorf("GeoUpdateInterval = %d, want the configured 24 left untouched by the stop", updater.GeoUpdateInterval()) + } + if calls.stopped != 1 || calls.registered != 0 { + t.Errorf( + "updater calls = %d registered/%d stopped, want the running updater cancelled", + calls.registered, calls.stopped, + ) + } +} + +func TestSyncGeoUpdaterIgnoresUnchangedParameters(t *testing.T) { + calls := stubGeoUpdater(t) + + updater.SetGeoAutoUpdate(false) + updater.SetGeoUpdateInterval(12) + stillDisabled := false + sameInterval := 12 + + syncGeoUpdater(&stillDisabled, &sameInterval) + + if updater.GeoAutoUpdate() || updater.GeoUpdateInterval() != 12 { + t.Errorf( + "geo settings = %v/%d, want them untouched", + updater.GeoAutoUpdate(), updater.GeoUpdateInterval(), + ) + } + if calls.registered != 0 || calls.stopped != 0 { + t.Errorf( + "updater calls = %d registered/%d stopped, want the running updater left alone", + calls.registered, calls.stopped, + ) + } +} + +// A profile apply reloads the setting out of config.yaml, so the core can end +// up with the updater running while the flag reads off. Reconciling only when +// the flag says on left that goroutine downloading GEO databases forever: the +// flag already matched what the app sent next, so syncGeoUpdater saw nothing to +// change and never cancelled it. +func TestReconcileGeoUpdaterStopsAnUpdaterTheProfileTurnedOff(t *testing.T) { + calls := stubGeoUpdater(t) + + updater.SetGeoAutoUpdate(false) + + reconcileGeoUpdater() + + if calls.stopped != 1 || calls.registered != 0 { + t.Errorf( + "updater calls = %d registered/%d stopped, want the updater cancelled", + calls.registered, calls.stopped, + ) + } +} + +func TestReconcileGeoUpdaterRegistersWhenTheSettingIsOn(t *testing.T) { + calls := stubGeoUpdater(t) + + updater.SetGeoAutoUpdate(true) + + reconcileGeoUpdater() + + if calls.registered != 1 || calls.stopped != 0 { + t.Errorf( + "updater calls = %d registered/%d stopped, want the updater registered", + calls.registered, calls.stopped, + ) + } +} + +func TestCurrentTestURLFallsBackToTheBuiltInProbe(t *testing.T) { + previous := testURL.Load() + t.Cleanup(func() { testURL.Store(previous) }) + + testURL.Store(nil) + if got := currentTestURL(); got != defaultTestURL { + t.Errorf("currentTestURL = %q, want the built-in probe", got) + } + + empty := "" + testURL.Store(&empty) + if got := currentTestURL(); got != defaultTestURL { + t.Errorf("currentTestURL = %q, want an empty setting to fall back", got) + } + + custom := "https://example.test/generate_204" + setTestURL(custom) + if got := currentTestURL(); got != custom { + t.Errorf("currentTestURL = %q, want %q", got, custom) + } +} + +func TestDelayTestSlotsBoundConcurrency(t *testing.T) { + if cap(delayTestSlots) != delayTestConcurrency { + t.Fatalf("delayTestSlots capacity = %d, want %d", cap(delayTestSlots), delayTestConcurrency) + } + + for i := 0; i < delayTestConcurrency; i++ { + if !acquireDelayTestSlot(context.Background()) { + t.Fatalf("slot %d was refused while the semaphore still had room", i) + } + } + + blocked := make(chan struct{}) + go func() { + acquireDelayTestSlot(context.Background()) + close(blocked) + }() + + select { + case <-blocked: + t.Fatal("acquireDelayTestSlot handed out more slots than the configured concurrency") + case <-time.After(20 * time.Millisecond): + } + + releaseDelayTestSlot() + select { + case <-blocked: + case <-time.After(time.Second): + t.Fatal("a released slot never reached the waiting caller") + } + + for i := 0; i < delayTestConcurrency; i++ { + releaseDelayTestSlot() + } +} + +func TestHandleUpdateGeoDataRejectsAnUnknownResource(t *testing.T) { + if message := handleUpdateGeoData("NOPE"); message == "" { + t.Error("an unknown geo resource reported success and silently did nothing") + } + if _, exists := geoResourceUpdaters["MMDB"]; !exists { + t.Error("MMDB is missing from the geo resource table") + } +} + +func TestSetTestURLIgnoresAnEmptyValue(t *testing.T) { + const configured = "https://example.test/generate_204" + previousDefault := constant.DefaultTestURL + previousStored := testURL.Load() + t.Cleanup(func() { + constant.DefaultTestURL = previousDefault + testURL.Store(previousStored) + }) + + setTestURL(configured) + setTestURL("") + + if constant.DefaultTestURL != configured { + t.Errorf("DefaultTestURL = %q, an empty setup param cleared mihomo's own default", constant.DefaultTestURL) + } + if got := currentTestURL(); got != configured { + t.Errorf("currentTestURL() = %q, want %q", got, configured) + } +} + +func TestTestDelayRejectsAnUnknownProxyWithoutWaitingForASlot(t *testing.T) { + for i := 0; i < delayTestConcurrency; i++ { + delayTestSlots <- struct{}{} + } + t.Cleanup(func() { + for i := 0; i < delayTestConcurrency; i++ { + <-delayTestSlots + } + }) + + done := make(chan *Delay, 1) + go func() { + done <- handleTestDelay(&TestDelayParams{ProxyName: "missing", Timeout: 1}) + }() + + select { + case delay := <-done: + if delay.Value != -1 { + t.Errorf("value = %d, want -1", delay.Value) + } + case <-time.After(time.Second): + t.Fatal("an unknown proxy queued behind a saturated delay-test semaphore") + } +} + +func settleMessageBatcher() { + time.Sleep(3 * messageBatchInterval) +} + +func withGeoResourceUpdaters(t *testing.T, updaters map[string]func() error) { + t.Helper() + previous := geoResourceUpdaters + geoResourceUpdaters = updaters + t.Cleanup(func() { + geoResourceUpdaters = previous + for name := range updaters { + releaseGeoUpdate(name) + } + }) +} + +func TestHandleUpdateGeoDataRunsOneUpdatePerResource(t *testing.T) { + const resource = "TEST" + started := make(chan struct{}, 2) + release := make(chan struct{}) + var calls atomic.Int32 + + withGeoResourceUpdaters(t, map[string]func() error{ + resource: func() error { + calls.Add(1) + started <- struct{}{} + <-release + return nil + }, + }) + + if message := handleUpdateGeoData(resource); message != "" { + t.Fatalf("handleUpdateGeoData = %q, want no error", message) + } + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("the first update never started") + } + + if message := handleUpdateGeoData(resource); message != "" { + t.Fatalf("the duplicate request reported %q", message) + } + select { + case <-started: + t.Fatal("a second update started while the first was still running; both close the mmap'd database") + case <-time.After(100 * time.Millisecond): + } + close(release) + + if got := calls.Load(); got != 1 { + t.Errorf("the updater ran %d times, want 1", got) + } + + deadline := time.Now().Add(time.Second) + for !claimGeoUpdate(resource) { + if time.Now().After(deadline) { + t.Fatal("the in-flight claim was never released, so the resource can never be updated again") + } + time.Sleep(5 * time.Millisecond) + } + releaseGeoUpdate(resource) +} + +func TestGeoUpdateHookBlocksAManualUpdateOfTheSameResource(t *testing.T) { + const resource = "MMDB" + ran := make(chan struct{}, 1) + + withGeoResourceUpdaters(t, map[string]func() error{ + resource: func() error { + ran <- struct{}{} + return nil + }, + }) + + updater.GeoUpdateHook(resource, true, false, nil) + + if message := handleUpdateGeoData(resource); message != "" { + t.Fatalf("handleUpdateGeoData = %q, want no error", message) + } + select { + case <-ran: + t.Fatal("a manual update ran while the auto updater was already updating the same resource") + case <-time.After(100 * time.Millisecond): + } + + updater.GeoUpdateHook(resource, false, false, nil) + + if !claimGeoUpdate(resource) { + t.Fatal("the hook never cleared the claim, so the resource stays blocked for the rest of the process") + } + releaseGeoUpdate(resource) + settleMessageBatcher() +} + +// The hook fires for the automatic updater, which can start and finish inside a +// manual update of the same resource. Releasing the manual claim there lets a +// second manual update run alongside the first. +func TestGeoUpdateHookDoesNotReleaseAManualClaim(t *testing.T) { + const resource = "GEOIP" + started := make(chan struct{}, 2) + release := make(chan struct{}) + var calls atomic.Int32 + + withGeoResourceUpdaters(t, map[string]func() error{ + resource: func() error { + calls.Add(1) + started <- struct{}{} + <-release + return nil + }, + }) + + if message := handleUpdateGeoData(resource); message != "" { + t.Fatalf("handleUpdateGeoData = %q, want no error", message) + } + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("the manual update never started") + } + + // An automatic pass over the same resource, start to finish, while the + // manual one is still running. + updater.GeoUpdateHook(resource, true, false, nil) + updater.GeoUpdateHook(resource, false, false, nil) + + if message := handleUpdateGeoData(resource); message != "" { + t.Fatalf("the duplicate request reported %q", message) + } + select { + case <-started: + t.Fatal("a second manual update started while the first was still running") + case <-time.After(100 * time.Millisecond): + } + close(release) + + deadline := time.Now().Add(time.Second) + for !claimGeoUpdate(resource) { + if time.Now().After(deadline) { + t.Fatal("the manual claim was never released") + } + time.Sleep(5 * time.Millisecond) + } + releaseGeoUpdate(resource) + if got := calls.Load(); got != 1 { + t.Errorf("the updater ran %d times, want 1", got) + } + settleMessageBatcher() +} + +// An empty config.yaml is how the app expresses "no profile selected". mihomo's +// own reader rejects it. +func TestLoadConfigTreatsAnEmptyProfileAsTheDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := loadConfig(path) + + if err != nil { + t.Fatalf("loadConfig = %v, want no error for an empty profile", err) + } + if cfg == nil { + t.Fatal("loadConfig returned no config for an empty profile") + } +} + +func TestLoadConfigReportsAMissingFile(t *testing.T) { + _, err := loadConfig(filepath.Join(t.TempDir(), "absent.yaml")) + + if err == nil { + t.Fatal("loadConfig accepted a path that does not exist") + } + if !strings.Contains(err.Error(), "absent.yaml") { + t.Errorf("loadConfig = %v, want it to name the missing file", err) + } +} + +func TestLoadConfigReportsMalformedYaml(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("proxies: [unterminated\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + if _, err := loadConfig(path); err == nil { + t.Fatal("loadConfig accepted malformed yaml") + } +} + +func withSetupConfig(t *testing.T, apply func(*SetupParams) error) { + t.Helper() + previous := setupConfig + setupConfig = apply + t.Cleanup(func() { setupConfig = previous }) + + isInit.Store(true) + isRunning.Store(true) + t.Cleanup(func() { + isInit.Store(false) + isRunning.Store(false) + }) +} + +// applyConfig rolls back to the default config when the profile fails to parse. +// That rollback is the whole recovery: the error reaches the host and the +// listeners keep serving while the user picks another profile. +func TestHandleSetupConfigKeepsTheListenerWhenTheProfileFails(t *testing.T) { + withSetupConfig(t, func(*SetupParams) error { return errors.New("bad profile") }) + + got := handleSetupConfig(defaultSetupParams()) + + if got != "bad profile" { + t.Fatalf("handleSetupConfig = %q, want the apply error", got) + } + if !isRunning.Load() { + t.Error("a failed apply stopped the listeners") + } +} + +func TestHandleSetupConfigLeavesTheListenerAloneOnSuccess(t *testing.T) { + withSetupConfig(t, func(*SetupParams) error { return nil }) + + got := handleSetupConfig(defaultSetupParams()) + + if got != "" { + t.Fatalf("handleSetupConfig = %q, want no error", got) + } + if !isRunning.Load() { + t.Error("a successful apply stopped the listeners") + } +} + +// The host abandons a delay test after a fixed time. +func TestAcquireDelayTestSlotGivesUpOnTheDeadline(t *testing.T) { + for i := 0; i < delayTestConcurrency; i++ { + delayTestSlots <- struct{}{} + } + t.Cleanup(func() { + for i := 0; i < delayTestConcurrency; i++ { + <-delayTestSlots + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + refused := make(chan bool, 1) + go func() { refused <- acquireDelayTestSlot(ctx) }() + + select { + case granted := <-refused: + if granted { + t.Fatal("acquireDelayTestSlot handed out a slot the semaphore did not have") + } + case <-time.After(time.Second): + t.Fatal("acquireDelayTestSlot ignored the deadline and kept queueing") + } +} + +func TestDelayTestTimeoutFallsBackWhenUnset(t *testing.T) { + if got := delayTestTimeout(250); got != 250*time.Millisecond { + t.Errorf("delayTestTimeout(250) = %v, want 250ms", got) + } + for _, unset := range []int64{0, -1} { + if got := delayTestTimeout(unset); got != defaultDelayTestTimeout { + t.Errorf("delayTestTimeout(%d) = %v, want the default %v", unset, got, defaultDelayTestTimeout) + } + } +} + +func TestTestDelayQueuesWhenTheTimeoutIsUnset(t *testing.T) { + for i := 0; i < delayTestConcurrency; i++ { + delayTestSlots <- struct{}{} + } + t.Cleanup(func() { + for i := 0; i < delayTestConcurrency; i++ { + <-delayTestSlots + } + }) + + tunnel.UpdateProxies(map[string]constant.Proxy{"queued": namedProxy("queued")}, nil) + t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) + + done := make(chan *Delay, 1) + go func() { + // A refused port rather than the default URL: the probe this is proving + // will eventually happen should not leave the machine. + done <- handleTestDelay(&TestDelayParams{ + ProxyName: "queued", + TestUrl: "http://127.0.0.1:1", + }) + }() + + select { + case <-done: + t.Fatal("handleTestDelay gave up immediately on an unset timeout, want it queueing for the default") + case <-time.After(200 * time.Millisecond): + } + + // Hand over a slot and join, so nothing is still reading the tunnel when + // the cleanup below replaces it. tunnel.Proxies and tunnel.Providers are + // unsynchronised reads of maps UpdateProxies writes, so an unjoined reader + // is a reported race whatever the timing. + <-delayTestSlots + delay := <-done + delayTestSlots <- struct{}{} + + if delay.Value != -1 { + t.Errorf("value = %d, want -1 for a probe against a refused port", delay.Value) + } +} + +func TestTestDelayStopsQueueingOnceTheTimeoutIsSpent(t *testing.T) { + for i := 0; i < delayTestConcurrency; i++ { + delayTestSlots <- struct{}{} + } + t.Cleanup(func() { + for i := 0; i < delayTestConcurrency; i++ { + <-delayTestSlots + } + }) + + // A proxy the lookup actually resolves, so the call reaches the semaphore + // instead of bailing out on an unknown name. + tunnel.UpdateProxies(map[string]constant.Proxy{"queued": namedProxy("queued")}, nil) + t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) + + done := make(chan *Delay, 1) + go func() { + done <- handleTestDelay(&TestDelayParams{ProxyName: "queued", Timeout: 20}) + }() + + select { + case delay := <-done: + if delay != nil { + t.Errorf("delay = %+v, want no result for a test that never got a slot", delay) + } + case <-time.After(time.Second): + t.Fatal("handleTestDelay outlived the timeout it was given, waiting for a slot") + } +} diff --git a/core/constant.go b/core/constant.go index f7ae1a4453..12df058631 100644 --- a/core/constant.go +++ b/core/constant.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "github.com/metacubex/mihomo/adapter/provider" P "github.com/metacubex/mihomo/component/process" "github.com/metacubex/mihomo/constant" @@ -29,10 +28,8 @@ type UpdateParams struct { Mode *tunnel.TunnelMode `json:"mode"` LogLevel *log.LogLevel `json:"log-level"` IPv6 *bool `json:"ipv6"` - Sniffing *bool `json:"sniffing"` TCPConcurrent *bool `json:"tcp-concurrent"` ExternalController *string `json:"external-controller"` - Interface *string `json:"interface-name"` UnifiedDelay *bool `json:"unified-delay"` GeoAutoUpdate *bool `json:"geo-auto-update"` GeoUpdateInterval *int `json:"geo-update-interval"` @@ -47,6 +44,11 @@ type tunSchema struct { RouteAddress *[]netip.Prefix `yaml:"route-address" json:"route-address,omitempty"` } +type SideLoadParams struct { + ProviderName string `json:"providerName"` + Data string `json:"data"` +} + type ChangeProxyParams struct { GroupName string `json:"group-name"` ProxyName string `json:"proxy-name"` @@ -98,7 +100,6 @@ const ( closeConnectionMethod CoreMethod = "closeConnection" getExternalProvidersMethod CoreMethod = "getExternalProviders" getExternalProviderMethod CoreMethod = "getExternalProvider" - getCountryCodeMethod CoreMethod = "getCountryCode" getMemoryMethod CoreMethod = "getMemory" updateGeoDataMethod CoreMethod = "updateGeoData" updateExternalProviderMethod CoreMethod = "updateExternalProvider" @@ -126,7 +127,7 @@ type Delay struct { type Message struct { Type MessageType `json:"type"` - Data interface{} `json:"data"` + Data any `json:"data"` } const ( @@ -143,8 +144,3 @@ type GeoUpdateStatus struct { Skipped bool `json:"skipped,omitempty"` Error string `json:"error,omitempty"` } - -func (message *Message) Json() (string, error) { - data, err := json.Marshal(message) - return string(data), err -} diff --git a/core/dial_pipe.go b/core/dial_pipe.go index 6acccb2f2e..be1351f35e 100644 --- a/core/dial_pipe.go +++ b/core/dial_pipe.go @@ -1,13 +1,13 @@ -//go:build windows && !cgo +//go:build windows && !(android && cgo) package main import ( - "io" + "net" "github.com/Microsoft/go-winio" ) -func dial(path string) (io.ReadWriteCloser, error) { +func dial(path string) (net.Conn, error) { return winio.DialPipe(path, nil) } diff --git a/core/dial_socket.go b/core/dial_socket.go index 47d58e1ef6..90f845a65d 100644 --- a/core/dial_socket.go +++ b/core/dial_socket.go @@ -1,15 +1,14 @@ -//go:build !cgo && !windows +//go:build !(android && cgo) && !windows package main import ( "fmt" - "io" "net" "strconv" ) -func dial(arg string) (io.ReadWriteCloser, error) { +func dial(arg string) (net.Conn, error) { _, err := strconv.Atoi(arg) if err != nil { return net.Dial("unix", arg) diff --git a/core/go.mod b/core/go.mod index fc40d0cf7c..3937a0b4d3 100644 --- a/core/go.mod +++ b/core/go.mod @@ -7,8 +7,6 @@ replace github.com/metacubex/mihomo => ./Clash.Meta require ( github.com/Microsoft/go-winio v0.6.2 github.com/metacubex/mihomo v0.0.0-00010101000000-000000000000 - golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e - golang.org/x/sync v0.11.0 ) require ( @@ -16,7 +14,6 @@ require ( github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect github.com/ajg/form v1.7.1 // indirect github.com/akutz/memconn v0.1.0 // indirect - github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect @@ -25,7 +22,7 @@ require ( github.com/coreos/go-iptables v0.8.0 // indirect github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dunglas/httpsfv v1.0.2 // indirect - github.com/enfein/mieru/v3 v3.34.0 // indirect + github.com/enfein/mieru/v3 v3.35.0 // indirect github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 // indirect github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 // indirect @@ -37,7 +34,6 @@ require ( github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect - github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect github.com/gofrs/uuid/v5 v5.4.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/snappy v1.0.0 // indirect @@ -45,7 +41,6 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/huin/goupnp v1.3.0 // indirect github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 // indirect github.com/josharian/native v1.1.0 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect @@ -55,51 +50,53 @@ require ( github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 // indirect - github.com/metacubex/amneziawg-go v0.0.0-20260612143004-19b4f1cdd5ec // indirect + github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832 // indirect github.com/metacubex/ascon v0.1.0 // indirect - github.com/metacubex/bart v0.26.0 // indirect + github.com/metacubex/bart v0.29.0 // indirect github.com/metacubex/bbolt v0.0.0-20260706163408-d4ec34ad7c48 // indirect github.com/metacubex/blake3 v0.1.0 // indirect github.com/metacubex/chacha v0.1.5 // indirect github.com/metacubex/chi v0.1.1 // indirect - github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a // indirect + github.com/metacubex/connect-ip-go v0.0.0-20260727083417-67ccdb0cf771 // indirect github.com/metacubex/cpu v0.1.1 // indirect github.com/metacubex/edwards25519 v1.2.0 // indirect github.com/metacubex/fswatch v0.1.1 // indirect github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 // indirect - github.com/metacubex/gvisor v0.0.0-20251227095601-261ec1326fe8 // indirect + github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22 // indirect github.com/metacubex/hkdf v0.1.0 // indirect github.com/metacubex/hpke v0.1.0 // indirect - github.com/metacubex/http v0.1.6 // indirect - github.com/metacubex/jls-quic-go v0.0.0-20260717074316-85a8decdd355 // indirect - github.com/metacubex/jls-tls v0.0.0-20260716145614-4bf88db633e2 // indirect - github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df // indirect + github.com/metacubex/http v0.1.7 // indirect + github.com/metacubex/jls-quic-go v0.0.0-20260727080412-732f2fc9a34d // indirect + github.com/metacubex/jls-tls v0.0.0-20260723084315-67adc0e2f796 // indirect + github.com/metacubex/jsonv2 v0.0.0-20260721082349-16b4998c8f89 // indirect github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 // indirect github.com/metacubex/mhurl v0.1.0 // indirect + github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13 // indirect github.com/metacubex/mlkem v0.1.0 // indirect github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb // indirect github.com/metacubex/qpack v0.6.0 // indirect - github.com/metacubex/quic-go v0.59.1-0.20260606115121-0662b57ad5bf // indirect + github.com/metacubex/quic-go v0.61.1-0.20260727080200-2548683b76f4 // indirect github.com/metacubex/randv2 v0.2.0 // indirect - github.com/metacubex/restls-client-go v0.1.8 // indirect + github.com/metacubex/restls-client-go v0.1.9 // indirect github.com/metacubex/sevenzip v1.6.4 // indirect github.com/metacubex/sing v0.5.7 // indirect github.com/metacubex/sing-mux v0.3.10 // indirect - github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3 // indirect + github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51 // indirect github.com/metacubex/sing-shadowsocks v0.2.12 // indirect github.com/metacubex/sing-shadowsocks2 v0.2.7 // indirect - github.com/metacubex/sing-tun v0.4.21 // indirect + github.com/metacubex/sing-tun v0.4.22 // indirect github.com/metacubex/sing-vmess v0.2.5 // indirect - github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c // indirect + github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0 // indirect github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 // indirect github.com/metacubex/ssh v0.1.0 // indirect - github.com/metacubex/tailscale v0.0.0-20260711142031-e2257fe61058 // indirect - github.com/metacubex/tailscale-wireguard-go v0.0.0-20260623093519-06ea214022e4 // indirect + github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb // indirect + github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2 // indirect github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c // indirect - github.com/metacubex/tls v0.1.7 // indirect + github.com/metacubex/tls v0.1.8 // indirect github.com/metacubex/utls v1.8.7 // indirect github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f // indirect github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 // indirect + github.com/metacubex/zerotier-go v0.0.0-20260813124750-13fa6f45da5f // indirect github.com/miekg/dns v1.1.63 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mroth/weightedrand/v2 v2.1.0 // indirect @@ -133,9 +130,10 @@ require ( go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.33.0 // indirect + golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.35.0 // indirect - golang.org/x/oauth2 v0.24.0 // indirect + golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/term v0.29.0 // indirect golang.org/x/text v0.22.0 // indirect diff --git a/core/go.sum b/core/go.sum index b8aa979025..fefb4d2318 100644 --- a/core/go.sum +++ b/core/go.sum @@ -10,8 +10,6 @@ github.com/ajg/form v1.7.1 h1:OsnBDzTkrWdrxvEnO68I72ZVGJGNaMwPhoAm0V+llgc= github.com/ajg/form v1.7.1/go.mod h1:HL757PzLyNkj5AIfptT6L+iGNeXTlnrr/oDePGc/y7Q= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= @@ -34,8 +32,8 @@ github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= -github.com/enfein/mieru/v3 v3.34.0 h1:8yeaPORvfSQtdfEH+Arw9wttDyFxkik8My4zFdGu79Y= -github.com/enfein/mieru/v3 v3.34.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= +github.com/enfein/mieru/v3 v3.35.0 h1:AKQT6l9R6xcYaUocpfjqE/vWEvYMxpl+071bhfDXkMY= +github.com/enfein/mieru/v3 v3.35.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I= github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g= @@ -46,6 +44,8 @@ github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 h1:tlDMEdcPRQKBE github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1/go.mod h1:4RfsapbGx2j/vU5xC/5/9qB3kn9Awp1YDiEnN43QrJ4= github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010 h1:fuGucgPk5dN6wzfnxl3D0D3rVLw4v2SbBT9jb4VnxzA= github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010/go.mod h1:JtBcj7sBuTTRupn7c2bFspMDIObMJsVK8TeUvpShPok= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -62,8 +62,6 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= -github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= -github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -81,8 +79,6 @@ github.com/google/tink/go v1.6.1 h1:t7JHqO8Ath2w2ig5vjwQYJzhGEZymedQc90lQXUBa4I= github.com/google/tink/go v1.6.1/go.mod h1:IGW53kTgag+st5yPhKKwJ6u2l+SSp5/v9XF7spovjlY= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= -github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 h1:q3OEI9RaN/wwcx+qgGo6ZaoJkCiDYe/gjDLfq7lQQF4= github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905/go.mod h1:VvGYjkZoJyKqlmT1yzakUs4mfKMNB0XdODP0+rdml6k= github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= @@ -96,8 +92,8 @@ github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/4 github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/reedsolomon v1.12.3 h1:tzUznbfc3OFwJaTebv/QdhnFf2Xvb7gZ24XaHLBPmdc= github.com/klauspost/reedsolomon v1.12.3/go.mod h1:3K5rXwABAvzGeR01r6pWZieUALXO/Tq7bFKGIb4m4WI= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= @@ -106,12 +102,12 @@ github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 h1:LqWr0vb9zDNuQS+jJd4fnRYk/SEI7KJ7TDe/L4WFK48= github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78/go.mod h1:BTBG/iVY7rg3qq5WdVCg0GFk58CSvCDSbjy8I7kEx/c= -github.com/metacubex/amneziawg-go v0.0.0-20260612143004-19b4f1cdd5ec h1:nRHevF7PmvDKjkYPjQCU7NUfVrr3Sry4QOPxpqoyo8U= -github.com/metacubex/amneziawg-go v0.0.0-20260612143004-19b4f1cdd5ec/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY= +github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832 h1:mAIVFc8Z9jziImzNl5bXUBVU2bTEmP2sXfosn/6YxPs= +github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY= github.com/metacubex/ascon v0.1.0 h1:6ZWxmXYszT1XXtwkf6nxfFhc/OTtQ9R3Vyj1jN32lGM= github.com/metacubex/ascon v0.1.0/go.mod h1:eV5oim4cVPPdEL8/EYaTZ0iIKARH9pnhAK/fcT5Kacc= -github.com/metacubex/bart v0.26.0 h1:d/bBTvVatfVWGfQbiDpYKI1bXUJgjaabB2KpK1Tnk6w= -github.com/metacubex/bart v0.26.0/go.mod h1:DCcyfP4MC+Zy7sLK7XeGuMw+P5K9mIRsYOBgiE8icsI= +github.com/metacubex/bart v0.29.0 h1:yVc/lLdDqn+7FShI0qUghStHl0/d3mn4EH/I5R5niKk= +github.com/metacubex/bart v0.29.0/go.mod h1:DCcyfP4MC+Zy7sLK7XeGuMw+P5K9mIRsYOBgiE8icsI= github.com/metacubex/bbolt v0.0.0-20260706163408-d4ec34ad7c48 h1:D+AoQ7g/ZeZAHAapelrZB0xUNRcDGuoNLcGmHCnC9Ss= github.com/metacubex/bbolt v0.0.0-20260706163408-d4ec34ad7c48/go.mod h1:yOfzykxfYIEVOtEys6KOKwxy75M2I/KtOQ+p3Kzc8/k= github.com/metacubex/blake3 v0.1.0 h1:KGnjh/56REO7U+cgZA8dnBhxdP7jByrG7hTP+bu6cqY= @@ -120,8 +116,8 @@ github.com/metacubex/chacha v0.1.5 h1:fKWMb/5c7ZrY8Uoqi79PPFxl+qwR7X/q0OrsAubyX2 github.com/metacubex/chacha v0.1.5/go.mod h1:Djn9bPZxLTXbJFSeyo0/qzEzQI+gUSSzttuzZM75GH8= github.com/metacubex/chi v0.1.1 h1:GajmCIXSIPAZNxqTTYpkCrkY9suBz9wjIdgPcoQ5K1A= github.com/metacubex/chi v0.1.1/go.mod h1:/CvDXe8jZD/ecU8Y7fmS5XKLRR44UDmC6/IBkRIb6Z4= -github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a h1:Ph5UfTWDsGruZ+v95Df1ycTflQFmpZBFg2LUvj2kx/M= -github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a/go.mod h1:xYC8Ik7/rN6no+vTRuWMEziGwm3brA0wNM/zZP9qhOQ= +github.com/metacubex/connect-ip-go v0.0.0-20260727083417-67ccdb0cf771 h1:nLGBvwQ2vmsD+gU/XWBQH3xaYCM3vQCJIqPfw3mc/L0= +github.com/metacubex/connect-ip-go v0.0.0-20260727083417-67ccdb0cf771/go.mod h1:9FjDcopUc+tgUva3dauAVzrYPHd0rant+zkvyECzkMg= github.com/metacubex/cpu v0.1.1 h1:rRV5HGmeuGzjiKI3hYbL0dCd0qGwM7VUtk4ICXD06mI= github.com/metacubex/cpu v0.1.1/go.mod h1:09VEt4dSRLR+bOA8l4w4NDuzGZ8n5dkMv7e8axgEeTU= github.com/metacubex/edwards25519 v1.2.0 h1:pIQZLBsjQgg3Nl/c86YYFEUAbL5qQRnPq4LrgIw0KK4= @@ -130,72 +126,76 @@ github.com/metacubex/fswatch v0.1.1 h1:jqU7C/v+g0qc2RUFgmAOPoVvfl2BXXUXEumn6oQux github.com/metacubex/fswatch v0.1.1/go.mod h1:czrTT7Zlbz7vWft8RQu9Qqh+JoX+Nnb+UabuyN1YsgI= github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 h1:cjd4biTvOzK9ubNCCkQ+ldc4YSH/rILn53l/xGBFHHI= github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759/go.mod h1:UHOv2xu+RIgLwpXca7TLrXleEd4oR3sPatW6IF8wU88= -github.com/metacubex/gvisor v0.0.0-20251227095601-261ec1326fe8 h1:hUL81H0Ic/XIDkvtn9M1pmfDdfid7JzYQToY4Ps1TvQ= -github.com/metacubex/gvisor v0.0.0-20251227095601-261ec1326fe8/go.mod h1:8LpS0IJW1VmWzUm3ylb0e2SK5QDm5lO/2qwWLZgRpBU= +github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22 h1:C4d4/BA8WuQmJnSpjy0UN4vJvRMjU3133rxIYQgbPPs= +github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22/go.mod h1:mBJW3UXUusd8ZYO5M6mig0uScIey9iIubgdIiQAk2ug= github.com/metacubex/hkdf v0.1.0 h1:fPA6VzXK8cU1foc/TOmGCDmSa7pZbxlnqhl3RNsthaA= github.com/metacubex/hkdf v0.1.0/go.mod h1:3seEfds3smgTAXqUGn+tgEJH3uXdsUjOiduG/2EtvZ4= github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ= github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U= -github.com/metacubex/http v0.1.6 h1:xvXuvXMCMxCWMF5nEJF4yiKvXL+p2atWMzs37e80m1I= -github.com/metacubex/http v0.1.6/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg= -github.com/metacubex/jls-quic-go v0.0.0-20260717074316-85a8decdd355 h1:DD4qraoIY3IbkHxaBbpJR68sWNzrdKHfFRfFhT6m1W8= -github.com/metacubex/jls-quic-go v0.0.0-20260717074316-85a8decdd355/go.mod h1:dbfkDUKqohALRmgUrVIOLirCNfKjgaos9nfTnBRTpS8= -github.com/metacubex/jls-tls v0.0.0-20260716145614-4bf88db633e2 h1:n+qiR8opOJEerewq/S92zGoHBDXZb+dKmyYq0aKJtis= -github.com/metacubex/jls-tls v0.0.0-20260716145614-4bf88db633e2/go.mod h1:mmqs889W/TqPlfNRDa2UyJvRiLyiTJIEnWHkcj3SKB8= -github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df h1:S0vBzqjXok24VopstOgPd1JdgglW9tXehrqvwpQWbQ8= -github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df/go.mod h1:F4sVXat6QjPXkNsKRDyyG3BhSkxPFFnRPEIwmmyCgbg= +github.com/metacubex/http v0.1.7 h1:dEaQmijUaR/2QKqgjBUmPZCRuh1zBfDqB6ZRNLwvbHg= +github.com/metacubex/http v0.1.7/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg= +github.com/metacubex/jls-quic-go v0.0.0-20260727080412-732f2fc9a34d h1:OF3TUGKdHRrRMp7nb0Pa72QYwKPol9NqSy2yTcP2Sog= +github.com/metacubex/jls-quic-go v0.0.0-20260727080412-732f2fc9a34d/go.mod h1:dH8StPlpZ7atSUuTnfTnRXvJK22nOtQVimNwQ9Gmk58= +github.com/metacubex/jls-tls v0.0.0-20260723084315-67adc0e2f796 h1:1iI4np/Dm1ztCfTW2LoN166O6n728HXMc11aIazYdps= +github.com/metacubex/jls-tls v0.0.0-20260723084315-67adc0e2f796/go.mod h1:mmqs889W/TqPlfNRDa2UyJvRiLyiTJIEnWHkcj3SKB8= +github.com/metacubex/jsonv2 v0.0.0-20260721082349-16b4998c8f89 h1:v41RWXQbmSD1wqOuoN5VvRHo78wuSziG/bbsLdeH2TQ= +github.com/metacubex/jsonv2 v0.0.0-20260721082349-16b4998c8f89/go.mod h1:F4sVXat6QjPXkNsKRDyyG3BhSkxPFFnRPEIwmmyCgbg= github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 h1:hJwCVlE3ojViC35MGHB+FBr8TuIf3BUFn2EQ1VIamsI= github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604/go.mod h1:lpmN3m269b3V5jFCWtffqBLS4U3QQoIid9ugtO+OhVc= github.com/metacubex/mhurl v0.1.0 h1:ZdW4Zxe3j3uJ89gNytOazHu6kbHn5owutN/VfXOI8GE= github.com/metacubex/mhurl v0.1.0/go.mod h1:2qpQImCbXoUs6GwJrjuEXKelPyoimsIXr07eNKZdS00= +github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13 h1:aOU4avd9u7uFivubzn+SjFmEDWAkGF+gvZ0ecEdeg64= +github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13/go.mod h1:+bbwALZI0pbi2auSG5A3ptdpV2DZ2eLObziXo+P7oj0= github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I= github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ= github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb h1:wk6mHYPURSUvWcUv72gNP79oiylFsscBSDPJ6ieV6Iw= github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb/go.mod h1:73ZrCfhdkW4F2E2GAlta3km/S2RHhFNogCMtWZV2anQ= github.com/metacubex/qpack v0.6.0 h1:YqClGIMOpiRYLjV1qOs483Od08MdPgRnHjt90FuaAKw= github.com/metacubex/qpack v0.6.0/go.mod h1:lKGSi7Xk94IMvHGOmxS9eIei3bvIqpOAImEBsaOwTkA= -github.com/metacubex/quic-go v0.59.1-0.20260606115121-0662b57ad5bf h1:WvIp5pF+LLZwg0I6555eMVlKFrLrqQqPKob6XW6niyo= -github.com/metacubex/quic-go v0.59.1-0.20260606115121-0662b57ad5bf/go.mod h1:2YEQEvFrZ5V76oynMBDTlN+4fdnSHCa2uNJxv3cm1HU= +github.com/metacubex/quic-go v0.61.1-0.20260727080200-2548683b76f4 h1:7oX6CpHVqoUXNyyt0MTuO5ywd1qVe1S1t3jXWMW7bYc= +github.com/metacubex/quic-go v0.61.1-0.20260727080200-2548683b76f4/go.mod h1:KTmibo7vZo0gCxdFWrPEQ/u/RGt92mJkrXDE2Qg2/AU= github.com/metacubex/randv2 v0.2.0 h1:uP38uBvV2SxYfLj53kuvAjbND4RUDfFJjwr4UigMiLs= github.com/metacubex/randv2 v0.2.0/go.mod h1:kFi2SzrQ5WuneuoLLCMkABtiBu6VRrMrWFqSPyj2cxY= -github.com/metacubex/restls-client-go v0.1.8 h1:0kQ699TWnbK3bWLhCPE0oIiBJLN+errOLQ9Z3/P1lbA= -github.com/metacubex/restls-client-go v0.1.8/go.mod h1:BN/U52vPw7j8VTSh2vleD/MnmVKCov84mS5VcjVHH4g= +github.com/metacubex/restls-client-go v0.1.9 h1:QmLKwVFuAjB6rL9lQNKj8CuwHqGMJjV36oskeBPEtVs= +github.com/metacubex/restls-client-go v0.1.9/go.mod h1:BN/U52vPw7j8VTSh2vleD/MnmVKCov84mS5VcjVHH4g= github.com/metacubex/sevenzip v1.6.4 h1:OIL+DeOeSAbKNsjqxcYUMiarRmX6Kaxakb0GT7E9Oik= github.com/metacubex/sevenzip v1.6.4/go.mod h1:FP3X9bzFKj9wPxifGN9B3w2fIEicMjzKYIGIhnu+1pw= github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM= github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w= github.com/metacubex/sing-mux v0.3.10 h1:r5CuZ/KuwFsEcRRwpLvzLncW4fDzNfmSEcBEWcy/+94= github.com/metacubex/sing-mux v0.3.10/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc= -github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3 h1:PnMby5+kZXTl/CFDHfxMbMTaSRD+uMKMsrDYVQyAmX8= -github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk= +github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51 h1:FpQj1XfYBIm4LAml+LDjvnfjDs20cSDsFmgjThOqmks= +github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk= github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE= github.com/metacubex/sing-shadowsocks v0.2.12/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU= github.com/metacubex/sing-shadowsocks2 v0.2.7 h1:hSuuc0YpsfiqYqt1o+fP4m34BQz4e6wVj3PPBVhor3A= github.com/metacubex/sing-shadowsocks2 v0.2.7/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE= -github.com/metacubex/sing-tun v0.4.21 h1:MjbPYytM240VH70lO182v2Sg/JthMmx6R6z6E2fCR2I= -github.com/metacubex/sing-tun v0.4.21/go.mod h1:g4I/JNplDBhXLF+aQWgFbhNeJPSXQOWS9HvLeNvkgeA= +github.com/metacubex/sing-tun v0.4.22 h1:6ARRJ2BIFD1u4r/DTMNcxaNuGyimfXEeUyD4iFJRaZs= +github.com/metacubex/sing-tun v0.4.22/go.mod h1:9T0SN+FHvzRrn7hgl2kmXy5v8xzzLKW1hITButIPAco= github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE= github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q= -github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c h1:tH9FuQW357zp2xAGzkoZTGpNGMVmEFZov0iV5M2S5ew= -github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c/go.mod h1:eQZDJTx+IH3k4mXqaOJ3VJ9h9ZqOl60F7TLi5wAU51Q= +github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0 h1:4g/ObGTfHaY2zXcFjKOUO9aAFk+yFjxsZfUPQPG6J8c= +github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0/go.mod h1:H27NH3IgI4qk1Wj8kS9pneNMJYEuxFskcQfkfVW9vnw= github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 h1:DK2l6m2Fc85H2BhiAPgbJygiWhesPlfGmF+9Vw6ARdk= github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141/go.mod h1:/yI4OiGOSn0SURhZdJF3CbtPg3nwK700bG8TZLMBvAg= github.com/metacubex/ssh v0.1.0 h1:iGfr99qk/eMHzUnQ/0bTxXT8+8SWqLSHBWDHoAhngzw= github.com/metacubex/ssh v0.1.0/go.mod h1:NUtl0d+/f2cG9ECEpMM8iCVOpmggQlC13oLeDUONDlU= -github.com/metacubex/tailscale v0.0.0-20260711142031-e2257fe61058 h1:TFM/yl2XvyMDQY16GHDxXtuJMa8DGz7roRpY6plxLMk= -github.com/metacubex/tailscale v0.0.0-20260711142031-e2257fe61058/go.mod h1:rDpd7cy7itpPi3Kgb90fUgSnKwMXHYBk0R5DuNRiNNA= -github.com/metacubex/tailscale-wireguard-go v0.0.0-20260623093519-06ea214022e4 h1:ui12xOUPsCAYwzI7+7Hw2mVhrAbWDGNQZcrBiELhGk0= -github.com/metacubex/tailscale-wireguard-go v0.0.0-20260623093519-06ea214022e4/go.mod h1:W00EeoPdd2jbV3+ZUa0m2b7FdKx5B1LFsXvEpy7cToQ= +github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb h1:EE4+ehlygfki5qJvkIYs6zJXhv7WFAX+4cDQi/b6cY8= +github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb/go.mod h1:fMMYScJPYt8Ss3D4VhontA2uV2N9NPZbbXsPP6LgQeo= +github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2 h1:nKyLVJEU4jsLVx+rdqbDUbEKTX/Sdu161AeVoZC9tlY= +github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2/go.mod h1:W00EeoPdd2jbV3+ZUa0m2b7FdKx5B1LFsXvEpy7cToQ= github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c h1:+2C1UshSLzaogLY/IDz1XhOLxjhs3rkhVHB4eCN9Zto= github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw= -github.com/metacubex/tls v0.1.7 h1:7YW+7z2xGH1bozSg04q3gKNd7GBSiTSXIrQY2SMnfoE= -github.com/metacubex/tls v0.1.7/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM= +github.com/metacubex/tls v0.1.8 h1:BHF2payjtxC7wdR3tnq8vl3FAgEQB6/pPjP2tJNTsP4= +github.com/metacubex/tls v0.1.8/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM= github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs= github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f h1:FGBPRb1zUabhPhDrlKEjQ9lgIwQ6cHL4x8M9lrERhbk= github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f/go.mod h1:oPGcV994OGJedmmxrcK9+ni7jUEMGhR+uVQAdaduIP4= github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 h1:lhlqpYHopuTLx9xQt22kSA9HtnyTDmk5XjjQVCGHe2E= github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49/go.mod h1:MBeEa9IVBphH7vc3LNtW6ZujVXFizotPo3OEiHQ+TNU= +github.com/metacubex/zerotier-go v0.0.0-20260813124750-13fa6f45da5f h1:OS35JZobxU+AUv+9EjH/ULiKwyGciSK98n1CmxitDFo= +github.com/metacubex/zerotier-go v0.0.0-20260813124750-13fa6f45da5f/go.mod h1:8LxQupX5KaZ6lIcBMdaJ3u5xx7+OIRyq87uyyFA1Yto= github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= @@ -221,6 +221,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs= github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= @@ -303,10 +305,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= -golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -315,7 +314,6 @@ golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -340,5 +338,5 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -software.sslmate.com/src/go-pkcs12 v0.2.1 h1:tbT1jjaeFOF230tzOIRJ6U5S1jNqpsSyNjzDd58H3J8= -software.sslmate.com/src/go-pkcs12 v0.2.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/core/hub.go b/core/hub.go index ac3e8a3641..c170a5ea87 100644 --- a/core/hub.go +++ b/core/hub.go @@ -3,61 +3,60 @@ package main import ( "cmp" "context" + "errors" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "slices" + "strconv" + "sync" + "time" + "github.com/metacubex/mihomo/adapter" "github.com/metacubex/mihomo/adapter/outboundgroup" "github.com/metacubex/mihomo/common/observable" "github.com/metacubex/mihomo/common/utils" - "github.com/metacubex/mihomo/component/mmdb" "github.com/metacubex/mihomo/component/resolver" "github.com/metacubex/mihomo/component/updater" "github.com/metacubex/mihomo/config" "github.com/metacubex/mihomo/constant" "github.com/metacubex/mihomo/constant/features" - cp "github.com/metacubex/mihomo/constant/provider" "github.com/metacubex/mihomo/hub/executor" "github.com/metacubex/mihomo/listener" "github.com/metacubex/mihomo/log" "github.com/metacubex/mihomo/tunnel" "github.com/metacubex/mihomo/tunnel/statistic" - "golang.org/x/exp/slices" - "net" - "os" - "path/filepath" - "runtime" - "runtime/debug" - "strconv" - "sync/atomic" - "time" ) var ( - isInit atomic.Bool - externalProviders = map[string]cp.Provider{} - logSubscriber observable.Subscription[log.Event] + logMu sync.Mutex + logSubscriber observable.Subscription[log.Event] + logCancel context.CancelFunc ) func handleInitClash(params *InitParams) bool { - runLock.Lock() - defer runLock.Unlock() - version = params.Version + configMu.Lock() + defer configMu.Unlock() + sdkVersion.Store(int32(params.Version)) constant.SetHomeDir(params.HomeDir) isInit.Store(true) return true } func handleStartListener() bool { - runLock.Lock() - defer runLock.Unlock() - isRunning = true - updateListeners() + configMu.Lock() + defer configMu.Unlock() + isRunning.Store(true) + updateListeners(currentConfig) resolver.ResetConnection() return true } func handleStopListener() bool { - runLock.Lock() - defer runLock.Unlock() - isRunning = false + configMu.Lock() + defer configMu.Unlock() + isRunning.Store(false) listener.StopListener() resolver.ResetConnection() return true @@ -69,6 +68,7 @@ func handleGetIsInit() bool { func handleForceGC() { log.Infoln("[APP] request force GC") + tunnel.InvalidateAllProxies() runtime.GC() if features.Android { debug.FreeOSMemory() @@ -76,97 +76,129 @@ func handleForceGC() { } func handleShutdown() bool { - stopListeners() + handleStopLog() + + configMu.Lock() + isRunning.Store(false) + listener.StopListener() + updater.StopGeoUpdater() executor.Shutdown() - handleForceGC() + currentConfig = nil isInit.Store(false) + configMu.Unlock() + + handleForceGC() return true } func handleValidateConfig(path string) string { - buf, err := readFile(path) - _, err = config.UnmarshalRawConfig(buf) + buf, err := os.ReadFile(path) if err != nil { return err.Error() } + if _, err = config.UnmarshalRawConfig(buf); err != nil { + return err.Error() + } return "" } -func handleGetProxies() ProxiesData { - runLock.Lock() - defer runLock.Unlock() - - nameList := config.GetProxyNameList() +const globalProxyName = "GLOBAL" - proxies := tunnel.AllProxies() +func isProxyGroupType(adapterType constant.AdapterType) bool { + switch adapterType { + case constant.Selector, constant.URLTest, constant.Fallback, constant.Relay, constant.LoadBalance: + return true + default: + return false + } +} +func proxyGroupNames( + nameList []string, + typeOf func(name string) (constant.AdapterType, bool), +) []string { hasGlobal := false - - allNames := make([]string, 0, len(nameList)+1) + names := make([]string, 0, len(nameList)+1) for _, name := range nameList { - if name == "GLOBAL" { + if name == globalProxyName { hasGlobal = true } - - p, ok := proxies[name] - if !ok || p == nil { + adapterType, ok := typeOf(name) + if !ok || !isProxyGroupType(adapterType) { continue } - switch p.Type() { - case constant.Selector, constant.URLTest, constant.Fallback, constant.Relay, constant.LoadBalance: - allNames = append(allNames, name) - default: - } + names = append(names, name) } if !hasGlobal { - if p, ok := proxies["GLOBAL"]; ok && p != nil { - allNames = append([]string{"GLOBAL"}, allNames...) + if adapterType, ok := typeOf(globalProxyName); ok && isProxyGroupType(adapterType) { + names = append([]string{globalProxyName}, names...) } } + return names +} + +func handleGetProxies() ProxiesData { + proxies := tunnel.AllProxies() + + allNames := proxyGroupNames(config.GetProxyNameList(), func(name string) (constant.AdapterType, bool) { + p, ok := proxies[name] + if !ok || p == nil { + return 0, false + } + return p.Type(), true + }) + return ProxiesData{ All: allNames, Proxies: proxies, } } -func handleChangeProxy(params *ChangeProxyParams, fn func(string string)) { - runLock.Lock() - go func() { - defer runLock.Unlock() - groupName := params.GroupName - proxyName := params.ProxyName - proxies := tunnel.AllProxies() - group, ok := proxies[groupName] - if !ok { - fn("Not found group") - return - } - adapterProxy, ok := group.(*adapter.Proxy) - if !ok { - fn("Group has invalid proxy type") - return - } - selector, ok := adapterProxy.ProxyAdapter.(outboundgroup.SelectAble) - if !ok { - fn("Group is not selectable") - return - } - if proxyName == "" { - selector.ForceSet(proxyName) - } else { - err := selector.Set(proxyName) - if err != nil { - fn(err.Error()) - return - } - } +var ( + errGroupNotFound = errors.New("Not found group") + errGroupInvalidType = errors.New("Group has invalid proxy type") + errGroupNotSelect = errors.New("Group is not selectable") +) - fn("") - return - }() +func lookupProxy(name string) constant.Proxy { + return tunnel.AllProxies()[name] +} + +func selectableGroup(groupName string) (outboundgroup.SelectAble, error) { + group := lookupProxy(groupName) + if group == nil { + return nil, errGroupNotFound + } + adapterProxy, ok := group.(*adapter.Proxy) + if !ok { + return nil, errGroupInvalidType + } + selector, ok := adapterProxy.ProxyAdapter.(outboundgroup.SelectAble) + if !ok { + return nil, errGroupNotSelect + } + return selector, nil +} + +func handleChangeProxy(params *ChangeProxyParams) string { + selectMu.Lock() + defer selectMu.Unlock() + + selector, err := selectableGroup(params.GroupName) + if err != nil { + return err.Error() + } + if params.ProxyName == "" { + selector.ForceSet(params.ProxyName) + return "" + } + if err := selector.Set(params.ProxyName); err != nil { + return err.Error() + } + return "" } func handleGetTraffic(onlyStatisticsProxy bool) Traffic { @@ -189,80 +221,73 @@ func handleResetTraffic() { statistic.DefaultManager.ResetStatistic() } -func handleAsyncTestDelay(params *TestDelayParams, fn func(*Delay)) { - batchKey := params.ProxyName + "\x00" + params.TestUrl - mBatch.Go(batchKey, func() (bool, error) { - testUrl := params.TestUrl - if testUrl == "" { - testUrl = constant.DefaultTestURL - } - delayData := &Delay{ - Name: params.ProxyName, - Url: testUrl, - Value: -1, - } +func delayValue(delay uint16) int32 { + if delay == 0 { + return -1 + } + return int32(delay) +} - expectedStatus, err := utils.NewUnsignedRanges[uint16]("") - if err != nil { - fn(delayData) - return false, nil - } +var anyDelayTestStatus utils.IntRanges[uint16] + +func delayTestTimeout(milliseconds int64) time.Duration { + if milliseconds <= 0 { + return defaultDelayTestTimeout + } + return time.Duration(milliseconds) * time.Millisecond +} - ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*time.Duration(params.Timeout)) - defer cancel() +func handleTestDelay(params *TestDelayParams) *Delay { + url := params.TestUrl + if url == "" { + url = currentTestURL() + } + delayData := &Delay{ + Name: params.ProxyName, + Url: url, + Value: -1, + } - proxies := tunnel.AllProxies() - proxy := proxies[params.ProxyName] + proxy := lookupProxy(params.ProxyName) + if proxy == nil { + return delayData + } - if proxy == nil { - fn(delayData) - return false, nil - } - delay, err := proxy.URLTest(ctx, testUrl, expectedStatus) - if err != nil || delay == 0 { - fn(delayData) - return false, nil - } + ctx, cancel := context.WithTimeout(context.Background(), delayTestTimeout(params.Timeout)) + defer cancel() - delayData.Value = int32(delay) - fn(delayData) - return false, nil - }) + if !acquireDelayTestSlot(ctx) { + return nil + } + defer releaseDelayTestSlot() + + delay, err := proxy.URLTest(ctx, url, anyDelayTestStatus) + if err != nil { + return delayData + } + + delayData.Value = delayValue(delay) + return delayData } func handleGetConnections() *statistic.Snapshot { - runLock.Lock() - defer runLock.Unlock() return statistic.DefaultManager.Snapshot() } func handleCloseConnections() bool { - runLock.Lock() - defer runLock.Unlock() - closeConnections() - return true -} - -func closeConnections() { statistic.DefaultManager.Range(func(c statistic.Tracker) bool { - err := c.Close() - if err != nil { - return false - } + _ = c.Close() return true }) + return true } func handleResetConnections() bool { - runLock.Lock() - defer runLock.Unlock() resolver.ResetConnection() return true } func handleCloseConnection(connectionId string) bool { - runLock.Lock() - defer runLock.Unlock() c := statistic.DefaultManager.Get(connectionId) if c == nil { return false @@ -272,11 +297,9 @@ func handleCloseConnection(connectionId string) bool { } func handleGetExternalProviders() []ExternalProvider { - runLock.Lock() - defer runLock.Unlock() - externalProviders = getExternalProvidersRaw() - eps := make([]ExternalProvider, 0) - for _, p := range externalProviders { + providers := externalProviders() + eps := make([]ExternalProvider, 0, len(providers)) + for _, p := range providers { externalProvider, err := toExternalProvider(p) if err != nil { continue @@ -290,72 +313,130 @@ func handleGetExternalProviders() []ExternalProvider { } func handleGetExternalProvider(externalProviderName string) *ExternalProvider { - runLock.Lock() - defer runLock.Unlock() - externalProvider, exist := externalProviders[externalProviderName] + p, exist := lookupExternalProvider(externalProviderName) if !exist { return nil } - e, err := toExternalProvider(externalProvider) + externalProvider, err := toExternalProvider(p) if err != nil { return nil } - return e + return externalProvider } -func handleUpdateGeoData(geoType string) { - go func() { - switch geoType { - case "MMDB": - updater.UpdateMMDB() - return - case "ASN": - updater.UpdateASN() - return - case "GEOIP": - updater.UpdateGeoIp() - return - case "GEOSITE": - updater.UpdateGeoSite() - return - } - }() +var geoResourceUpdaters = map[string]func() error{ + "MMDB": updater.UpdateMMDB, + "ASN": updater.UpdateASN, + "GEOIP": updater.UpdateGeoIp, + "GEOSITE": updater.UpdateGeoSite, } -func handleUpdateExternalProvider(providerName string, fn func(value string)) { - go func() { - runLock.Lock() - externalProvider, exist := externalProviders[providerName] - runLock.Unlock() - if !exist { - fn("external provider is not exist") - return - } - err := externalProvider.Update() - if err != nil { - fn(err.Error()) - return - } - fn("") - }() +const ( + geoUpdateScope = "geo:" + providerUpdateScope = "provider:" +) + +var ( + updateMu sync.Mutex + updateInFlight = map[string]bool{} + geoHookClaims = map[string]bool{} +) + +func claimUpdate(key string) bool { + updateMu.Lock() + defer updateMu.Unlock() + if updateInFlight[key] { + return false + } + updateInFlight[key] = true + return true } -func handleSideLoadExternalProvider(providerName string, data []byte, fn func(value string)) { - go func() { - runLock.Lock() - defer runLock.Unlock() - externalProvider, exist := externalProviders[providerName] - if !exist { - fn("external provider is not exist") - return - } - err := sideUpdateExternalProvider(externalProvider, data) - if err != nil { - fn(err.Error()) - return +func releaseUpdate(key string) { + updateMu.Lock() + defer updateMu.Unlock() + delete(updateInFlight, key) + delete(geoHookClaims, key) +} + +func claimGeoUpdate(geoType string) bool { + return claimUpdate(geoUpdateScope + geoType) +} + +func releaseGeoUpdate(geoType string) { + releaseUpdate(geoUpdateScope + geoType) +} + +func claimGeoUpdateFromHook(geoType string) { + key := geoUpdateScope + geoType + updateMu.Lock() + defer updateMu.Unlock() + if updateInFlight[key] { + return + } + updateInFlight[key] = true + geoHookClaims[key] = true +} + +func releaseGeoUpdateFromHook(geoType string) { + key := geoUpdateScope + geoType + updateMu.Lock() + defer updateMu.Unlock() + if !geoHookClaims[key] { + return + } + delete(geoHookClaims, key) + delete(updateInFlight, key) +} + +func handleUpdateGeoData(geoType string) string { + update, exist := geoResourceUpdaters[geoType] + if !exist { + logError("updateGeoData: unknown geo resource %q", geoType) + return "unknown geo resource: " + geoType + } + if !claimGeoUpdate(geoType) { + return "" + } + safeGoDetached("updateGeoData("+geoType+")", func() { + defer releaseGeoUpdate(geoType) + if err := update(); err != nil { + logError("updateGeoData(%s) error: %v", geoType, err) } - fn("") - }() + }) + return "" +} + +func handleUpdateExternalProvider(providerName string) string { + p, exist := lookupExternalProvider(providerName) + if !exist { + return "external provider is not exist" + } + key := providerUpdateScope + providerName + if !claimUpdate(key) { + return "" + } + defer releaseUpdate(key) + if err := p.Update(); err != nil { + return err.Error() + } + return "" +} + +func handleSideLoadExternalProvider(providerName string, data []byte) string { + p, exist := lookupExternalProvider(providerName) + if !exist { + return "external provider is not exist" + } + key := providerUpdateScope + providerName + if !claimUpdate(key) { + return "external provider is updating" + } + defer releaseUpdate(key) + if err := sideUpdateExternalProvider(p, data); err != nil { + return err.Error() + } + return "" } func handleSuspend(suspended bool) bool { @@ -368,65 +449,74 @@ func handleSuspend(suspended bool) bool { } func handleStartLog() { - runLock.Lock() + logMu.Lock() + if logCancel != nil { + logCancel() + logCancel = nil + } if logSubscriber != nil { log.UnSubscribe(logSubscriber) + logSubscriber = nil } + ctx, cancel := context.WithCancel(context.Background()) subscriber := log.Subscribe() logSubscriber = subscriber - runLock.Unlock() + logCancel = cancel + logMu.Unlock() + go func() { - for logData := range subscriber { - if logData.LogLevel < log.Level() { - continue + defer func() { + logMu.Lock() + if logSubscriber == subscriber { + log.UnSubscribe(subscriber) + logSubscriber = nil + logCancel = nil } - message := &Message{ - Type: LogMessage, - Data: logData, + logMu.Unlock() + }() + for { + select { + case <-ctx.Done(): + return + case logData, ok := <-subscriber: + if !ok { + return + } + if logData.LogLevel < log.Level() { + continue + } + sendMessage(Message{ + Type: LogMessage, + Data: logData, + }) } - sendMessage(*message) } }() } func handleStopLog() { - runLock.Lock() - defer runLock.Unlock() + logMu.Lock() + defer logMu.Unlock() + if logCancel != nil { + logCancel() + logCancel = nil + } if logSubscriber != nil { log.UnSubscribe(logSubscriber) logSubscriber = nil } } -func handleGetCountryCode(ip string, fn func(value string)) { - go func() { - runLock.Lock() - defer runLock.Unlock() - codes := mmdb.IPInstance().LookupCode(net.ParseIP(ip)) - if len(codes) == 0 { - fn("") - return - } - fn(codes[0]) - }() -} - -func handleGetMemory(fn func(value uint64)) { - go func() { - fn(statistic.DefaultManager.Memory()) - }() +func handleGetMemory() uint64 { + return statistic.DefaultManager.Memory() } func handleGetConfig(path string) (*config.RawConfig, error) { - bytes, err := readFile(path) - if err != nil { - return nil, err - } - prof, err := config.UnmarshalRawConfig(bytes) + buf, err := os.ReadFile(path) if err != nil { return nil, err } - return prof, nil + return config.UnmarshalRawConfig(buf) } func handleCrash() { @@ -434,46 +524,47 @@ func handleCrash() { } func handleUpdateConfig(params *UpdateParams) string { - updateConfig(params) + if err := updateConfig(params); err != nil { + return err.Error() + } return "" } +// providerPaths derives the providers root and the directory belonging to one +// profile. +// +// The profile ID is an int64 rendered through strconv, so the last element can +// never carry a separator or a `..` — that is what keeps handleClearEffect from +// becoming a general-purpose privileged file deletion API. +func providerPaths(homeDir string, profileId int64) (root string, target string) { + root = filepath.Join(homeDir, "profiles", "providers") + return root, filepath.Join(root, strconv.FormatInt(profileId, 10)) +} + // handleClearEffect derives the provider directory from a profile ID so the // method cannot be used as a general-purpose privileged file deletion API. -func handleClearEffect(profileId int64, response MethodResponse) { - go func() { - if !isInit.Load() { - response.success("not initialized") - return - } - if profileId <= 0 { - response.success("invalid profile id") - return - } - providersRoot := filepath.Join( - constant.Path.HomeDir(), - "profiles", - "providers", - ) - providersPath := filepath.Join( - providersRoot, - strconv.FormatInt(profileId, 10), - ) - if err := os.RemoveAll(providersPath); err != nil { - response.success(err.Error()) - return - } - _ = os.Remove(providersRoot) - response.success("") - }() +func handleClearEffect(profileId int64) string { + if !isInit.Load() { + return "not initialized" + } + if profileId <= 0 { + return "invalid profile id" + } + providersRoot, providersPath := providerPaths(constant.Path.HomeDir(), profileId) + if err := os.RemoveAll(providersPath); err != nil { + return err.Error() + } + _ = os.Remove(providersRoot) + return "" } +var setupConfig = applyConfig + func handleSetupConfig(params *SetupParams) string { if !isInit.Load() { return "not initialized" } - err := applyConfig(params) - if err != nil { + if err := setupConfig(params); err != nil { return err.Error() } return "" @@ -481,18 +572,13 @@ func handleSetupConfig(params *SetupParams) string { func init() { adapter.UrlTestHook = func(url string, name string, delay uint16) { - delayData := &Delay{ - Url: url, - Name: name, - } - if delay == 0 { - delayData.Value = -1 - } else { - delayData.Value = int32(delay) - } sendMessage(Message{ Type: DelayMessage, - Data: delayData, + Data: &Delay{ + Url: url, + Name: name, + Value: delayValue(delay), + }, }) } statistic.DefaultRequestNotify = func(c statistic.Tracker) { @@ -508,6 +594,11 @@ func init() { }) } updater.GeoUpdateHook = func(geoType string, updating bool, skipped bool, updateErr error) { + if updating { + claimGeoUpdateFromHook(geoType) + } else { + releaseGeoUpdateFromHook(geoType) + } status := GeoUpdateStatus{ Type: geoType, Updating: updating, diff --git a/core/hub_test.go b/core/hub_test.go new file mode 100644 index 0000000000..6d9d56a32c --- /dev/null +++ b/core/hub_test.go @@ -0,0 +1,686 @@ +package main + +import ( + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/metacubex/mihomo/adapter" + "github.com/metacubex/mihomo/adapter/outbound" + "github.com/metacubex/mihomo/common/utils" + "github.com/metacubex/mihomo/config" + "github.com/metacubex/mihomo/constant" + cp "github.com/metacubex/mihomo/constant/provider" + "github.com/metacubex/mihomo/log" + "github.com/metacubex/mihomo/tunnel" +) + +func namedProxy(name string) constant.Proxy { + return adapter.NewProxy(outbound.NewDirectWithOption(outbound.DirectOption{Name: name})) +} + +// typeMap turns a name -> adapter type table into the lookup proxyGroupNames +// expects, reporting a miss for any name outside the table. +func typeMap(types map[string]constant.AdapterType) func(string) (constant.AdapterType, bool) { + return func(name string) (constant.AdapterType, bool) { + adapterType, ok := types[name] + return adapterType, ok + } +} + +func TestProxyGroupNamesKeepsOnlyGroups(t *testing.T) { + names := proxyGroupNames( + []string{"GLOBAL", "Auto", "Direct node", "Fall", "Balance", "Chain"}, + typeMap(map[string]constant.AdapterType{ + "GLOBAL": constant.Selector, + "Auto": constant.URLTest, + "Direct node": constant.Direct, + "Fall": constant.Fallback, + "Balance": constant.LoadBalance, + "Chain": constant.Relay, + }), + ) + + want := []string{"GLOBAL", "Auto", "Fall", "Balance", "Chain"} + if strings.Join(names, ",") != strings.Join(want, ",") { + t.Fatalf("proxyGroupNames = %v, want %v", names, want) + } +} + +func TestProxyGroupNamesPreservesListOrder(t *testing.T) { + names := proxyGroupNames( + []string{"C", "A", "B"}, + typeMap(map[string]constant.AdapterType{ + "A": constant.Selector, + "B": constant.Selector, + "C": constant.Selector, + }), + ) + + if strings.Join(names, ",") != "C,A,B" { + t.Fatalf("proxyGroupNames = %v, want the config order C,A,B", names) + } +} + +func TestProxyGroupNamesSkipsUnknownNames(t *testing.T) { + names := proxyGroupNames( + []string{"Known", "Missing"}, + typeMap(map[string]constant.AdapterType{"Known": constant.Selector}), + ) + + if len(names) != 1 || names[0] != "Known" { + t.Fatalf("proxyGroupNames = %v, want only the registered name", names) + } +} + +func TestProxyGroupNamesPrependsUnlistedGlobal(t *testing.T) { + names := proxyGroupNames( + []string{"Auto"}, + typeMap(map[string]constant.AdapterType{ + "Auto": constant.URLTest, + "GLOBAL": constant.Selector, + }), + ) + + want := []string{"GLOBAL", "Auto"} + if strings.Join(names, ",") != strings.Join(want, ",") { + t.Fatalf("proxyGroupNames = %v, want %v", names, want) + } +} + +func TestProxyGroupNamesDoesNotDuplicateListedGlobal(t *testing.T) { + names := proxyGroupNames( + []string{"Auto", "GLOBAL"}, + typeMap(map[string]constant.AdapterType{ + "Auto": constant.URLTest, + "GLOBAL": constant.Selector, + }), + ) + + want := []string{"Auto", "GLOBAL"} + if strings.Join(names, ",") != strings.Join(want, ",") { + t.Fatalf("proxyGroupNames = %v, want %v", names, want) + } +} + +func TestProxyGroupNamesOmitsMissingGlobal(t *testing.T) { + names := proxyGroupNames( + []string{"Auto"}, + typeMap(map[string]constant.AdapterType{"Auto": constant.URLTest}), + ) + + if len(names) != 1 || names[0] != "Auto" { + t.Fatalf("proxyGroupNames = %v, want no GLOBAL entry", names) + } +} + +func TestProxyGroupNamesTreatsGlobalLikeAnyOtherName(t *testing.T) { + types := map[string]constant.AdapterType{ + "Auto": constant.URLTest, + "GLOBAL": constant.Direct, + } + + unlisted := proxyGroupNames([]string{"Auto"}, typeMap(types)) + listed := proxyGroupNames([]string{"GLOBAL", "Auto"}, typeMap(types)) + + if strings.Join(unlisted, ",") != "Auto" { + t.Fatalf("unlisted GLOBAL = %v, want it filtered out like a listed one", unlisted) + } + if strings.Join(listed, ",") != "Auto" { + t.Fatalf("listed GLOBAL = %v, want it filtered out", listed) + } +} + +func TestProxyGroupNamesEmptyList(t *testing.T) { + names := proxyGroupNames(nil, typeMap(nil)) + if len(names) != 0 { + t.Fatalf("proxyGroupNames = %v, want empty", names) + } +} + +func TestIsProxyGroupType(t *testing.T) { + groups := []constant.AdapterType{ + constant.Selector, + constant.URLTest, + constant.Fallback, + constant.Relay, + constant.LoadBalance, + } + for _, adapterType := range groups { + if !isProxyGroupType(adapterType) { + t.Errorf("isProxyGroupType(%v) = false, want true", adapterType) + } + } + + singles := []constant.AdapterType{constant.Direct, constant.Reject} + for _, adapterType := range singles { + if isProxyGroupType(adapterType) { + t.Errorf("isProxyGroupType(%v) = true, want false", adapterType) + } + } +} + +func TestDelayValue(t *testing.T) { + tests := []struct { + delay uint16 + want int32 + }{ + {delay: 0, want: -1}, + {delay: 1, want: 1}, + {delay: 250, want: 250}, + {delay: 65535, want: 65535}, + } + for _, test := range tests { + if got := delayValue(test.delay); got != test.want { + t.Errorf("delayValue(%d) = %d, want %d", test.delay, got, test.want) + } + } +} + +func TestProviderPathsStayUnderTheRoot(t *testing.T) { + home := filepath.Join("var", "home") + root, target := providerPaths(home, 1234567890123) + + wantRoot := filepath.Join(home, "profiles", "providers") + if root != wantRoot { + t.Fatalf("root = %q, want %q", root, wantRoot) + } + wantTarget := filepath.Join(wantRoot, "1234567890123") + if target != wantTarget { + t.Fatalf("target = %q, want %q", target, wantTarget) + } +} + +// The ID is an int64 rendered through strconv, so no caller-supplied value can +// add a separator or climb out of the providers root. +func TestProviderPathsCannotEscape(t *testing.T) { + home := t.TempDir() + ids := []int64{1, -1, 0, 1 << 62, -(1 << 62)} + + for _, id := range ids { + root, target := providerPaths(home, id) + cleaned := filepath.Clean(target) + if !strings.HasPrefix(cleaned, root+string(filepath.Separator)) { + t.Errorf("providerPaths(%d) escaped: %q is outside %q", id, cleaned, root) + } + if filepath.Dir(cleaned) != root { + t.Errorf("providerPaths(%d) = %q, want a direct child of %q", id, cleaned, root) + } + } +} + +func TestHandleValidateConfigAcceptsAValidFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("mixed-port: 7890\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + if got := handleValidateConfig(path); got != "" { + t.Fatalf("handleValidateConfig = %q, want no error", got) + } +} + +func TestHandleValidateConfigReportsAMissingFile(t *testing.T) { + got := handleValidateConfig(filepath.Join(t.TempDir(), "absent.yaml")) + + if got == "" { + t.Fatal("handleValidateConfig accepted a path that does not exist") + } + if !strings.Contains(got, "absent.yaml") { + t.Errorf("handleValidateConfig = %q, want it to name the missing file", got) + } +} + +func TestHandleValidateConfigReportsMalformedYaml(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("proxies: [unterminated\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + if got := handleValidateConfig(path); got == "" { + t.Fatal("handleValidateConfig accepted malformed yaml") + } +} + +// The provider entry has to win, because that is the one handleGetProxies +// reports under the shared name and the one the host is asking about. +func TestLookupProxyPrefersTheProviderEntry(t *testing.T) { + base := namedProxy("shared") + fromProvider := newCachingProvider("subscription", "shared", "node-a") + + tunnel.UpdateProxies( + map[string]constant.Proxy{"shared": base, "DIRECT": namedProxy("DIRECT")}, + map[string]cp.ProxyProvider{"subscription": fromProvider}, + ) + t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) + + if got := lookupProxy("shared"); got == base { + t.Error("lookupProxy(shared) returned the base entry, want the provider one") + } + if got := lookupProxy("node-a"); got == nil { + t.Error("lookupProxy(node-a) = nil, want the provider entry") + } + if got := lookupProxy("DIRECT"); got != base && got == nil { + t.Error("lookupProxy(DIRECT) = nil, want the base entry") + } + if got := lookupProxy("missing"); got != nil { + t.Errorf("lookupProxy(missing) = %v, want nil", got) + } +} + +// A subscription refresh has to reach a delay test without a config apply, +// which is the whole reason lookupProxy may read a cache at all. +func TestLookupProxyFollowsAProviderUpdate(t *testing.T) { + provider := newCachingProvider("subscription", "old-node") + withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil) + + if lookupProxy("old-node") == nil { + t.Fatal("lookupProxy(old-node) = nil before the update") + } + + provider.setProxies("new-node") + + if lookupProxy("new-node") == nil { + t.Error("lookupProxy(new-node) = nil, a subscription refresh did not reach the lookup") + } + if got := lookupProxy("old-node"); got != nil { + t.Errorf("lookupProxy(old-node) = %v after the refresh dropped it, want nil", got) + } +} + +func TestHandleShutdownTearsDownBackgroundWork(t *testing.T) { + withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}}) + isInit.Store(true) + + cancelled := false + logMu.Lock() + logSubscriber = make(chan log.Event) + logCancel = func() { cancelled = true } + logMu.Unlock() + + handleShutdown() + + if currentConfig != nil { + t.Error("currentConfig survived shutdown, so updateConfig would still patch a dead config") + } + if isInit.Load() { + t.Error("isInit stayed true after shutdown") + } + + logMu.Lock() + subscriber, cancel := logSubscriber, logCancel + logMu.Unlock() + if subscriber != nil || cancel != nil { + t.Error("shutdown left the log stream subscribed, so events keep being pumped to a host that stopped the core") + } + if !cancelled { + t.Error("shutdown never cancelled the log pump") + } +} + +// The write itself touches one field on one group. +func TestHandleChangeProxyDoesNotWaitOutAConfigApply(t *testing.T) { + configMu.Lock() + defer configMu.Unlock() + + answered := make(chan string, 1) + go func() { + answered <- handleChangeProxy(&ChangeProxyParams{GroupName: "absent", ProxyName: "node"}) + }() + + select { + case message := <-answered: + if message != errGroupNotFound.Error() { + t.Errorf("message = %q, want %q", message, errGroupNotFound) + } + case <-time.After(time.Second): + t.Fatal("handleChangeProxy queued behind configMu, so selecting a node waits for the whole apply") + } +} + +// The exclusion it does need has to survive: patchSelectGroup and +// handleChangeProxy both write group selections with no lock inside mihomo. +func TestPatchSelectGroupSerialisesWithProxyChanges(t *testing.T) { + selectMu.Lock() + + patched := make(chan struct{}) + go func() { + patchSelectGroup(map[string]string{"group": "node"}) + close(patched) + }() + + select { + case <-patched: + selectMu.Unlock() + t.Fatal("patchSelectGroup wrote selections without taking selectMu") + case <-time.After(20 * time.Millisecond): + } + + selectMu.Unlock() + select { + case <-patched: + case <-time.After(time.Second): + t.Fatal("patchSelectGroup never ran after selectMu was released") + } +} + +// The hoisted constant has to keep meaning what the call meant, so a change to +// mihomo's parser cannot silently narrow which HTTP statuses a delay test +// accepts. +func TestAnyDelayTestStatusMatchesTheEmptyRange(t *testing.T) { + built, err := utils.NewUnsignedRanges[uint16]("") + if err != nil { + t.Fatalf("NewUnsignedRanges(\"\") error: %v", err) + } + if len(built) != len(anyDelayTestStatus) { + t.Fatalf("anyDelayTestStatus = %v, want %v", anyDelayTestStatus, built) + } + for _, status := range []uint16{0, 200, 204, 302, 404, 503} { + if !anyDelayTestStatus.Check(status) { + t.Errorf("status %d was rejected, so a reachable proxy reports as unreachable", status) + } + } +} + +// The host reads the proxy tables — a proxy list for the UI, a provider lookup +// for an update — while a config apply replaces them. Those reads used to be +// serialised against the apply by a lock the host held for both; the tunnel's +// own accessors take none, so under -race this is what proves the reads were +// put back under the lock the apply writes with. +func TestProxyTableReadsAreSerialisedAgainstAnApply(t *testing.T) { + withTunnelProviders(t, nil, nil) + + const rounds = 200 + var wg sync.WaitGroup + stop := make(chan struct{}) + + wg.Add(1) + go func() { + defer wg.Done() + for round := 0; round < rounds; round++ { + name := "subscription-" + strconv.Itoa(round) + tunnel.UpdateProxies( + map[string]constant.Proxy{"DIRECT": namedProxy("DIRECT")}, + map[string]cp.ProxyProvider{name: newCachingProvider(name, "node")}, + ) + tunnel.UpdateRules(nil, nil, map[string]cp.RuleProvider{ + name: &fakeRuleProvider{name: name, vehicle: cp.HTTP}, + }) + } + close(stop) + }() + + for reader := 0; reader < 4; reader++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + tunnel.AllProxies() + externalProviders() + lookupExternalProvider("subscription-0") + } + }() + } + + wg.Wait() +} + +// blockingProxyProvider holds its update open so a second request for the same +// provider can be observed while the first is still running. +type blockingProxyProvider struct { + fakeProxyProvider + started chan struct{} + release chan struct{} + calls atomic.Int32 +} + +func (p *blockingProxyProvider) Update() error { + p.calls.Add(1) + p.started <- struct{}{} + <-p.release + return nil +} + +func TestUpdateExternalProviderRunsOneAtATime(t *testing.T) { + const name = "subscription" + provider := &blockingProxyProvider{ + fakeProxyProvider: fakeProxyProvider{name: name, vehicle: cp.HTTP}, + started: make(chan struct{}, 2), + release: make(chan struct{}, 2), + } + withTunnelProviders(t, map[string]cp.ProxyProvider{name: provider}, nil) + // Released through the cleanup so a blocked update never wedges the run. + t.Cleanup(func() { close(provider.release) }) + + first := make(chan string, 1) + go func() { first <- handleUpdateExternalProvider(name) }() + + select { + case <-provider.started: + case <-time.After(time.Second): + t.Fatal("the first update never started") + } + + second := make(chan string, 1) + go func() { second <- handleUpdateExternalProvider(name) }() + + select { + case message := <-second: + if message != "" { + t.Fatalf("the duplicate request reported %q", message) + } + case <-time.After(time.Second): + t.Fatal("the duplicate request reached the provider instead of being coalesced") + } + select { + case <-provider.started: + t.Fatal("a second update started while the first was still running; both rewrite the same vehicle file") + default: + } + + provider.release <- struct{}{} + if message := <-first; message != "" { + t.Fatalf("the first update reported %q", message) + } + + if got := provider.calls.Load(); got != 1 { + t.Errorf("Update ran %d times, want 1", got) + } + if !claimUpdate(providerUpdateScope + name) { + t.Fatal("the in-flight claim was never released, so the provider can never be updated again") + } + releaseUpdate(providerUpdateScope + name) +} + +// cachingProvider counts how often the tunnel walked its proxy list, which is +// exactly what the AllProxies cache exists to avoid. +type cachingProvider struct { + fakeProxyProvider + mu sync.Mutex + proxies []constant.Proxy + version uint32 + reads int +} + +func newCachingProvider(name string, proxyNames ...string) *cachingProvider { + provider := &cachingProvider{ + fakeProxyProvider: fakeProxyProvider{name: name, vehicle: cp.HTTP}, + } + provider.setProxies(proxyNames...) + return provider +} + +func (p *cachingProvider) Proxies() []constant.Proxy { + p.mu.Lock() + defer p.mu.Unlock() + p.reads++ + return p.proxies +} + +func (p *cachingProvider) Version() uint32 { + p.mu.Lock() + defer p.mu.Unlock() + return p.version +} + +// setProxies mirrors mihomo's baseProvider.setProxies: a new list and a bumped +// version, which is the only signal a runtime provider update leaves behind. +func (p *cachingProvider) setProxies(proxyNames ...string) { + p.mu.Lock() + defer p.mu.Unlock() + p.proxies = nil + for _, name := range proxyNames { + p.proxies = append(p.proxies, namedProxy(name)) + } + p.version++ +} + +func (p *cachingProvider) readCount() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.reads +} + +func proxyNamesOf(proxies map[string]constant.Proxy) []string { + names := make([]string, 0, len(proxies)) + for name := range proxies { + names = append(names, name) + } + slices.Sort(names) + return names +} + +func TestAllProxiesServesRepeatedCallsFromCache(t *testing.T) { + provider := newCachingProvider("subscription", "node-a", "node-b") + withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil) + + first := tunnel.AllProxies() + reads := provider.readCount() + second := tunnel.AllProxies() + + if provider.readCount() != reads { + t.Errorf("the provider list was walked again for an unchanged tunnel (%d -> %d reads)", + reads, provider.readCount()) + } + if got, want := proxyNamesOf(second), proxyNamesOf(first); !slices.Equal(got, want) { + t.Errorf("cached answer = %v, want %v", got, want) + } +} + +// executor.ApplyConfig installs the providers (line 100, updateProxies) before +// it loads them (line 115, loadProvider -> Initial -> the fetcher's onUpdate -> +// setProxies), so a provider is in the tunnel with an empty list for as long as +// its subscription takes to parse. Nothing tells the cache the list arrived +// except the version the load bumps, and a read landing inside that window must +// not be what the cache keeps answering with. +func TestAllProxiesPicksUpAProviderThatLoadsAfterTheApply(t *testing.T) { + loading := newCachingProvider("subscription") + base := map[string]constant.Proxy{"DIRECT": namedProxy("DIRECT")} + + tunnel.UpdateProxies(base, map[string]cp.ProxyProvider{"subscription": loading}) + t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) + + // A UI poll landing between the two executor steps. + during := proxyNamesOf(tunnel.AllProxies()) + if !slices.Equal(during, []string{"DIRECT"}) { + t.Fatalf("mid-apply AllProxies = %v, want only the base proxies", during) + } + + // Initial() finished parsing the subscription. + loading.setProxies("node-a", "node-b") + + after := proxyNamesOf(tunnel.AllProxies()) + want := []string{"DIRECT", "node-a", "node-b"} + if !slices.Equal(after, want) { + t.Errorf("AllProxies = %v, want %v; a provider that loaded after the apply did not reach the tunnel", after, want) + } +} + +// forceGC is the host's "give the memory back" hook — Android calls it from +// onLowMemory and handleShutdown ends with it — and a cache the collection +// cannot reach defeats it. +func TestForceGCReleasesTheProxyCache(t *testing.T) { + provider := newCachingProvider("subscription", "node-a", "node-b") + withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil) + + tunnel.AllProxies() + warm := provider.readCount() + tunnel.AllProxies() + if provider.readCount() != warm { + t.Fatal("the cache was not warm, so this proves nothing about releasing it") + } + + handleForceGC() + + tunnel.AllProxies() + if provider.readCount() == warm { + t.Error("AllProxies still answered from cache after a forced GC, so the replaced proxies stay pinned") + } +} + +func TestAllProxiesFollowsAProviderUpdate(t *testing.T) { + provider := newCachingProvider("subscription", "node-a") + withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil) + + tunnel.AllProxies() + provider.setProxies("node-b", "node-c") + + got := proxyNamesOf(tunnel.AllProxies()) + if want := []string{"node-b", "node-c"}; !slices.Equal(got, want) { + t.Errorf("proxies = %v, want %v; a subscription refresh did not reach the tunnel", got, want) + } +} + +// A config apply replaces the maps outright, and the replacements start their +// own version counts — so identical versions across an apply say nothing about +// whether the proxies are the same. Only the invalidation on UpdateProxies +// catches this. +func TestAllProxiesFollowsAConfigApplyThatKeepsEveryVersion(t *testing.T) { + before := newCachingProvider("subscription", "old-node") + tunnel.UpdateProxies(nil, map[string]cp.ProxyProvider{"subscription": before}) + t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) + + tunnel.AllProxies() + + after := newCachingProvider("subscription", "new-node") + if after.Version() != before.Version() { + t.Fatalf("the two providers must share a version for this to test anything (%d vs %d)", + after.Version(), before.Version()) + } + tunnel.UpdateProxies(nil, map[string]cp.ProxyProvider{"subscription": after}) + + got := proxyNamesOf(tunnel.AllProxies()) + if want := []string{"new-node"}; !slices.Equal(got, want) { + t.Errorf("proxies = %v, want %v; the previous profile's nodes survived the apply", got, want) + } +} + +func TestHandleGetProxiesSeesAProviderUpdate(t *testing.T) { + provider := newCachingProvider("subscription", "node-a") + withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil) + + if _, exist := handleGetProxies().Proxies["node-a"]; !exist { + t.Fatal("the handler did not report the installed proxy") + } + + provider.setProxies("node-b") + + data := handleGetProxies() + if _, exist := data.Proxies["node-b"]; !exist { + t.Error("the handler kept serving the pre-refresh proxy list") + } + if _, exist := data.Proxies["node-a"]; exist { + t.Error("a proxy the refresh removed is still reported") + } +} diff --git a/core/ipc_test.go b/core/ipc_test.go new file mode 100644 index 0000000000..db3e62d926 --- /dev/null +++ b/core/ipc_test.go @@ -0,0 +1,637 @@ +//go:build !cgo + +package main + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "io" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/metacubex/mihomo/common/observable" + "github.com/metacubex/mihomo/log" +) + +type fakeConn struct { + mu sync.Mutex + written bytes.Buffer + readable *bytes.Reader + writeErr error + writeErrAfter int + closed bool + deadlines int +} + +func (fake *fakeConn) Read(p []byte) (int, error) { + if fake.readable == nil { + return 0, io.EOF + } + return fake.readable.Read(p) +} + +func (fake *fakeConn) Write(p []byte) (int, error) { + fake.mu.Lock() + defer fake.mu.Unlock() + if fake.writeErr != nil { + if fake.writeErrAfter <= 0 { + return 0, fake.writeErr + } + accepted := min(fake.writeErrAfter, len(p)) + fake.writeErrAfter -= accepted + written, err := fake.written.Write(p[:accepted]) + if err != nil { + return written, err + } + return written, fake.writeErr + } + return fake.written.Write(p) +} + +func (fake *fakeConn) setWriteErr(err error) { + fake.mu.Lock() + defer fake.mu.Unlock() + fake.writeErr = err + fake.written.Reset() +} + +func (fake *fakeConn) Close() error { + fake.mu.Lock() + defer fake.mu.Unlock() + fake.closed = true + return nil +} + +func (fake *fakeConn) SetWriteDeadline(time.Time) error { + fake.mu.Lock() + defer fake.mu.Unlock() + fake.deadlines++ + return nil +} + +func (fake *fakeConn) isClosed() bool { + fake.mu.Lock() + defer fake.mu.Unlock() + return fake.closed +} + +func (fake *fakeConn) frames(t *testing.T) [][]byte { + t.Helper() + fake.mu.Lock() + defer fake.mu.Unlock() + reader := bytes.NewReader(fake.written.Bytes()) + var frames [][]byte + for { + frame, err := readFrame(reader) + if err == io.EOF { + return frames + } + if err != nil { + t.Fatalf("readFrame error: %v", err) + } + frames = append(frames, frame) + } +} + +// swapConn installs a connection the way send reads one. The message batcher +// runs for the whole test binary and reads conn under connMu, so a bare +// assignment here is a data race against every event it happens to deliver. +func swapConn(next ipcConn) ipcConn { + connMu.Lock() + defer connMu.Unlock() + previous := conn + conn = next + return previous +} + +func captureFrames(t *testing.T, run func()) [][]byte { + t.Helper() + fake := &fakeConn{} + previous := swapConn(fake) + defer swapConn(previous) + run() + return fake.frames(t) +} + +func captureSingleFrame(t *testing.T, run func()) []byte { + t.Helper() + frames := captureFrames(t, run) + if len(frames) != 1 { + t.Fatalf("captured %d frames, want 1", len(frames)) + } + return frames[0] +} + +func TestWriteFrameReadFrameRoundTrip(t *testing.T) { + payloads := [][]byte{ + []byte(""), + []byte("{}"), + bytes.Repeat([]byte("x"), 70000), + } + + buffer := &bytes.Buffer{} + for _, payload := range payloads { + if _, err := writeFrame(buffer, payload); err != nil { + t.Fatalf("writeFrame error: %v", err) + } + } + + for i, payload := range payloads { + got, err := readFrame(buffer) + if err != nil { + t.Fatalf("readFrame %d error: %v", i, err) + } + if !bytes.Equal(got, payload) { + t.Errorf("frame %d length = %d, want %d", i, len(got), len(payload)) + } + } +} + +func TestWriteFrameRejectsOversizedPayload(t *testing.T) { + written, err := writeFrame(&bytes.Buffer{}, make([]byte, maxIPCFrameSize+1)) + + if written != 0 { + t.Errorf("writeFrame wrote %d bytes for a rejected payload, want 0", written) + } + if err == nil { + t.Fatal("writeFrame accepted a payload above the frame limit") + } + if !strings.Contains(err.Error(), "IPC frame exceeds") { + t.Errorf("writeFrame error = %v, want an IPC frame limit error", err) + } +} + +func TestReadFrameRejectsOversizedHeader(t *testing.T) { + header := make([]byte, 4) + binary.LittleEndian.PutUint32(header, maxIPCFrameSize+1) + + _, err := readFrame(bytes.NewReader(header)) + + if err == nil { + t.Fatal("readFrame accepted a header above the frame limit") + } + if !strings.Contains(err.Error(), "IPC frame exceeds") { + t.Errorf("readFrame error = %v, want an IPC frame limit error", err) + } +} + +func TestReadFrameRejectsTruncatedPayload(t *testing.T) { + header := make([]byte, 4) + binary.LittleEndian.PutUint32(header, 8) + truncated := append(header, []byte("abc")...) + + if _, err := readFrame(bytes.NewReader(truncated)); err == nil { + t.Fatal("readFrame accepted a truncated payload") + } +} + +func TestMethodResponseSuccessEnvelope(t *testing.T) { + frame := captureSingleFrame(t, func() { + MethodResponse{ID: "42"}.success(map[string]any{"ok": true}) + }) + + var envelope map[string]any + if err := json.Unmarshal(frame, &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope["id"] != "42" { + t.Errorf("id = %v, want 42", envelope["id"]) + } + if _, hasError := envelope["error"]; hasError { + t.Error("a successful response must omit the error field") + } + result, ok := envelope["result"].(map[string]any) + if !ok || result["ok"] != true { + t.Errorf("result = %v, want {ok: true}", envelope["result"]) + } +} + +func TestMethodResponseFailureEnvelope(t *testing.T) { + frame := captureSingleFrame(t, func() { + MethodResponse{ID: "7"}.failure("core_error", "boom", []string{"detail"}) + }) + + var envelope struct { + ID string `json:"id"` + Result any `json:"result"` + Error *MethodError `json:"error"` + } + if err := json.Unmarshal(frame, &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope.ID != "7" { + t.Errorf("id = %s, want 7", envelope.ID) + } + if envelope.Result != nil { + t.Errorf("result = %v, want null on failure", envelope.Result) + } + if envelope.Error == nil { + t.Fatal("failure response must carry an error") + } + if envelope.Error.Code != "core_error" || envelope.Error.Message != "boom" { + t.Errorf("error = %+v, want code core_error message boom", envelope.Error) + } +} + +func TestDecodeMethodArgumentsRejectsInvalidPayloads(t *testing.T) { + tests := []struct { + name string + arguments string + }{ + {name: "missing", arguments: ""}, + {name: "null", arguments: "null"}, + {name: "wrong type", arguments: `"not-an-object"`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + call := &MethodCall{ + ID: "1", + Method: initClashMethod, + Arguments: json.RawMessage(test.arguments), + } + target := InitParams{} + accepted := true + + frame := captureSingleFrame(t, func() { + accepted = decodeMethodArguments(call, MethodResponse{ID: call.ID}, &target) + }) + + if accepted { + t.Fatal("decodeMethodArguments accepted an invalid payload") + } + var envelope struct { + Error *MethodError `json:"error"` + } + if err := json.Unmarshal(frame, &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope.Error == nil || envelope.Error.Code != "invalid_arguments" { + t.Errorf("error = %+v, want code invalid_arguments", envelope.Error) + } + }) + } +} + +func TestDecodeMethodArgumentsAcceptsValidPayload(t *testing.T) { + call := &MethodCall{ + ID: "1", + Method: initClashMethod, + Arguments: json.RawMessage(`{"home-dir":"/tmp/flclash","version":3}`), + } + target := InitParams{} + + frames := captureFrames(t, func() { + if !decodeMethodArguments(call, MethodResponse{ID: call.ID}, &target) { + t.Fatal("decodeMethodArguments rejected a valid payload") + } + }) + + if len(frames) != 0 { + t.Errorf("a successful decode must not send a response, got %d frames", len(frames)) + } + if target.HomeDir != "/tmp/flclash" || target.Version != 3 { + t.Errorf("decoded params = %+v, want {/tmp/flclash 3}", target) + } +} + +func TestHandleMethodCallReportsUnknownMethod(t *testing.T) { + frame := captureSingleFrame(t, func() { + handleMethodCall( + &MethodCall{ID: "9", Method: CoreMethod("nopeMethod")}, + MethodResponse{ID: "9"}, + ) + }) + + var envelope struct { + Error *MethodError `json:"error"` + } + if err := json.Unmarshal(frame, &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope.Error == nil || envelope.Error.Code != "not_implemented" { + t.Fatalf("error = %+v, want code not_implemented", envelope.Error) + } + if !strings.Contains(envelope.Error.Message, "nopeMethod") { + t.Errorf("error message = %s, want it to name the method", envelope.Error.Message) + } +} + +func TestSendMessageBatchWrapsMessagesInMethodCall(t *testing.T) { + batch := []Message{ + {Type: DelayMessage, Data: Delay{Url: "https://example.test", Name: "a", Value: 12}}, + {Type: LogMessage, Data: "hello"}, + } + + frame := captureSingleFrame(t, func() { + sendMessageBatch(batch) + }) + + call := MethodCall{} + if err := json.Unmarshal(frame, &call); err != nil { + t.Fatalf("batch frame is not a MethodCall: %v", err) + } + if call.Method != messageMethod { + t.Errorf("method = %s, want %s", call.Method, messageMethod) + } + if call.ID != "" { + t.Errorf("id = %s, want an empty id for event calls", call.ID) + } + + var decoded []Message + if err := json.Unmarshal(call.Arguments, &decoded); err != nil { + t.Fatalf("arguments are not a message list: %v", err) + } + if len(decoded) != 2 { + t.Fatalf("decoded %d messages, want 2", len(decoded)) + } + if decoded[0].Type != DelayMessage || decoded[1].Type != LogMessage { + t.Errorf("decoded types = %s,%s, want delay,log", decoded[0].Type, decoded[1].Type) + } +} + +func TestSendWithoutConnectionDoesNotPanic(t *testing.T) { + previous := swapConn(nil) + defer swapConn(previous) + + send([]byte("{}")) +} + +func TestMethodResponseAnswersExactlyOnce(t *testing.T) { + response := newMethodResponse("11", nil) + + frames := captureFrames(t, func() { + response.success("first") + response.success("second") + response.failure("core_error", "late", nil) + }) + + if len(frames) != 1 { + t.Fatalf("captured %d frames, want 1; a second answer double-releases the platform callback", len(frames)) + } + var envelope struct { + Result string `json:"result"` + } + if err := json.Unmarshal(frames[0], &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope.Result != "first" { + t.Errorf("result = %q, want the first answer to win", envelope.Result) + } +} + +func TestSendMessageBatchDoesNotDoubleEncodeArguments(t *testing.T) { + frame := captureSingleFrame(t, func() { + sendMessageBatch([]Message{{Type: LoadedMessage, Data: "provider"}}) + }) + + var envelope struct { + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(frame, &envelope); err != nil { + t.Fatalf("batch frame is not valid JSON: %v", err) + } + if len(envelope.Arguments) == 0 || envelope.Arguments[0] != '[' { + t.Fatalf("arguments = %s, want a JSON array rather than an encoded string", envelope.Arguments) + } +} + +func TestHandleMethodCallReportsMissingArguments(t *testing.T) { + frame := captureSingleFrame(t, func() { + handleMethodCall( + &MethodCall{ID: "3", Method: getTrafficMethod}, + newMethodResponse("3", nil), + ) + }) + + var envelope struct { + Error *MethodError `json:"error"` + } + if err := json.Unmarshal(frame, &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope.Error == nil || envelope.Error.Code != "invalid_arguments" { + t.Errorf("error = %+v, want code invalid_arguments", envelope.Error) + } +} + +func TestSendArmsAWriteDeadlineOnEveryFrame(t *testing.T) { + fake := &fakeConn{} + previous := swapConn(fake) + defer swapConn(previous) + + send([]byte("{}")) + send([]byte("{}")) + + fake.mu.Lock() + defer fake.mu.Unlock() + if fake.deadlines != 2 { + t.Errorf("deadlines armed = %d, want one per frame", fake.deadlines) + } +} + +func TestSendDropsTheConnectionAfterAPartialWriteFailure(t *testing.T) { + fake := &fakeConn{writeErr: errors.New("host stopped reading"), writeErrAfter: 2} + previous := swapConn(fake) + defer swapConn(previous) + + send([]byte("{}")) + + if !fake.isClosed() { + t.Error("a half-written frame left the connection open; the stream is desynchronized") + } + if conn != nil { + t.Error("conn still points at the dead connection") + } + + send([]byte("{}")) +} + +func TestSendKeepsTheConnectionWhenNoBytesReachedTheWire(t *testing.T) { + fake := &fakeConn{writeErr: os.ErrDeadlineExceeded} + previous := swapConn(fake) + defer swapConn(previous) + + send([]byte("{}")) + + if fake.isClosed() { + t.Error("write backpressure closed the only control channel; the Core would have to be restarted") + } + connMu.Lock() + still := conn == ipcConn(fake) + connMu.Unlock() + if !still { + t.Error("conn was cleared even though the frame never reached the wire") + } + + fake.setWriteErr(nil) + send([]byte("{}")) + if frames := fake.frames(t); len(frames) != 1 { + t.Errorf("delivered %d frames after the stall, want the retried one", len(frames)) + } +} + +func TestSendRearmsTheFailureReportAfterAFrameGetsThrough(t *testing.T) { + fake := &fakeConn{writeErr: os.ErrDeadlineExceeded} + previous := swapConn(fake) + deliveryFailureReported.Store(false) + defer func() { + swapConn(previous) + deliveryFailureReported.Store(false) + }() + + send([]byte("{}")) + if !deliveryFailureReported.Load() { + t.Fatal("the first delivery failure was not reported") + } + + fake.setWriteErr(nil) + send([]byte("{}")) + + if deliveryFailureReported.Load() { + t.Error("a successful frame left the report latched; every later failure stays silent") + } +} + +func TestSafeGoAnswersWhenTheHandlerPanics(t *testing.T) { + response := newMethodResponse("5", nil) + done := make(chan struct{}) + + frames := captureFrames(t, func() { + safeGo(response, func() { + defer close(done) + panic("handler exploded") + }) + <-done + time.Sleep(50 * time.Millisecond) + }) + + if len(frames) != 1 { + t.Fatalf("captured %d frames, want the panic answered exactly once", len(frames)) + } + var envelope struct { + Error *MethodError `json:"error"` + } + if err := json.Unmarshal(frames[0], &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope.Error == nil || envelope.Error.Code != "internal_error" { + t.Errorf("error = %+v, want code internal_error", envelope.Error) + } + if !strings.Contains(envelope.Error.Message, "handler exploded") { + t.Errorf("error message = %q, want it to carry the panic value", envelope.Error.Message) + } +} + +func TestSafeGoDetachedSurvivesAPanic(t *testing.T) { + done := make(chan struct{}) + + safeGoDetached("test", func() { + defer close(done) + panic("background exploded") + }) + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("safeGoDetached never ran the task") + } + time.Sleep(50 * time.Millisecond) +} + +func drainLogStream(t *testing.T, subscriber observable.Subscription[log.Event], window time.Duration, match func(string) bool) bool { + t.Helper() + deadline := time.After(window) + for { + select { + case event, ok := <-subscriber: + if !ok { + return false + } + if match(event.Payload) { + return true + } + case <-deadline: + return false + } + } +} + +func TestSendFailureIsNotReportedThroughTheLogStream(t *testing.T) { + subscriber := log.Subscribe() + defer log.UnSubscribe(subscriber) + + previous := swapConn(nil) + deliveryFailureReported.Store(false) + defer func() { + swapConn(previous) + deliveryFailureReported.Store(false) + }() + + for i := 0; i < 8; i++ { + send([]byte("{}")) + } + + echoed := drainLogStream(t, subscriber, 100*time.Millisecond, func(payload string) bool { + return strings.Contains(payload, "conn nil") || strings.Contains(payload, "server write") + }) + if echoed { + t.Error("a failed send published a log event, which is batched and handed back to send") + } +} + +func TestLogErrorStillReachesTheLogStream(t *testing.T) { + subscriber := log.Subscribe() + defer log.UnSubscribe(subscriber) + + logError("delivery probe %d", 7) + + reached := drainLogStream(t, subscriber, time.Second, func(payload string) bool { + return strings.Contains(payload, "delivery probe 7") + }) + if !reached { + t.Fatal("logError never reached the log stream, so the send-path assertion above proves nothing") + } +} + +func TestDeliveryFailureIsReportedOncePerConnection(t *testing.T) { + deliveryFailureReported.Store(false) + defer deliveryFailureReported.Store(false) + + logDeliveryError("first") + if !deliveryFailureReported.Load() { + t.Fatal("the first delivery failure did not latch") + } + logDeliveryError("second") + + deliveryFailureReported.Store(false) + logDeliveryError("after a fresh connection") + if !deliveryFailureReported.Load() { + t.Error("the latch did not re-arm for a new connection") + } +} + +func TestHandleStartLogAndStopLogLifecycle(t *testing.T) { + handleStartLog() + logMu.Lock() + if logSubscriber == nil || logCancel == nil { + logMu.Unlock() + t.Fatal("handleStartLog did not initialize subscriber or cancel func") + } + logMu.Unlock() + + handleStartLog() + + handleStopLog() + logMu.Lock() + if logSubscriber != nil || logCancel != nil { + logMu.Unlock() + t.Fatal("handleStopLog did not clear subscriber or cancel func") + } + logMu.Unlock() +} diff --git a/core/lib.go b/core/lib.go index 7082f44374..e2ce8304ec 100644 --- a/core/lib.go +++ b/core/lib.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build android && cgo package main @@ -8,41 +8,55 @@ package main import "C" import ( - "context" "core/platform" t "core/tun" "encoding/json" "errors" + "fmt" + "net" + "strings" + "sync" + "sync/atomic" + "syscall" + "unsafe" + "github.com/metacubex/mihomo/component/dialer" "github.com/metacubex/mihomo/component/process" "github.com/metacubex/mihomo/constant" "github.com/metacubex/mihomo/dns" "github.com/metacubex/mihomo/listener/sing_tun" "github.com/metacubex/mihomo/log" - "golang.org/x/sync/semaphore" - "net" - "strings" - "sync" - "syscall" - "unsafe" ) -var eventListener unsafe.Pointer +var ( + eventListenerLock sync.RWMutex + eventListener unsafe.Pointer +) type TunHandler struct { listener *sing_tun.Listener callback unsafe.Pointer - limit *semaphore.Weighted + mu sync.RWMutex } func (th *TunHandler) start(fd int, stack, address, dns string) { - runLock.Lock() - defer runLock.Unlock() - _ = th.limit.Acquire(context.TODO(), 4) - defer th.limit.Release(4) + configMu.Lock() + defer configMu.Unlock() + + th.mu.Lock() th.initHook() + th.mu.Unlock() + + // t.Start runs outside th.mu on purpose. The hook is live from initHook + // onwards, and a socket opened anywhere inside Start reaches handleProtect + // on this very goroutine — an RLock taken while this one holds the write + // lock deadlocks the start outright. Nothing is lost by dropping it: both + // hooks return early until th.listener is set, which is below. tunListener := t.Start(fd, stack, address, dns) + + th.mu.Lock() + defer th.mu.Unlock() if tunListener != nil { log.Infoln("TUN address: %v", tunListener.Address()) th.listener = tunListener @@ -52,8 +66,8 @@ func (th *TunHandler) start(fd int, stack, address, dns string) { } func (th *TunHandler) close() { - _ = th.limit.Acquire(context.TODO(), 4) - defer th.limit.Release(4) + th.mu.Lock() + defer th.mu.Unlock() th.clear() } @@ -70,8 +84,8 @@ func (th *TunHandler) clear() { } func (th *TunHandler) handleProtect(fd int) { - _ = th.limit.Acquire(context.Background(), 1) - defer th.limit.Release(1) + th.mu.RLock() + defer th.mu.RUnlock() if th.listener == nil { return @@ -81,8 +95,8 @@ func (th *TunHandler) handleProtect(fd int) { } func (th *TunHandler) handleResolveProcess(source, target net.Addr) string { - _ = th.limit.Acquire(context.Background(), 1) - defer th.limit.Release(1) + th.mu.RLock() + defer th.mu.RUnlock() if th.listener == nil { return "" @@ -95,33 +109,55 @@ func (th *TunHandler) handleResolveProcess(source, target net.Addr) string { case "tcp", "tcp4", "tcp6": protocol = syscall.IPPROTO_TCP } - if version < 29 { + if sdkVersion.Load() < 29 { uid = platform.QuerySocketUidFromProcFs(source, target) } return resolveProcess(th.callback, protocol, source.String(), target.String(), uid) } -func (th *TunHandler) initHook() { - dialer.DefaultSocketHook = func(network, address string, conn syscall.RawConn) error { - if platform.ShouldBlockConnection() { - return errBlocked +var ( + installHooksOnce sync.Once + activeTunHandler atomic.Pointer[TunHandler] +) + +func installHooks() { + installHooksOnce.Do(func() { + dialer.DefaultSocketHook = func(network, address string, conn syscall.RawConn) error { + if platform.ShouldBlockConnection() { + return errBlocked + } + th := activeTunHandler.Load() + if th == nil { + return nil + } + return conn.Control(func(fd uintptr) { + th.handleProtect(int(fd)) + }) } - return conn.Control(func(fd uintptr) { - tunHandler.handleProtect(int(fd)) - }) - } - process.DefaultPackageNameResolver = func(metadata *constant.Metadata) (string, error) { - src, dst := metadata.RawSrcAddr, metadata.RawDstAddr - if src == nil || dst == nil { - return "", process.ErrInvalidNetwork + process.DefaultPackageNameResolver = func(metadata *constant.Metadata) (string, error) { + th := activeTunHandler.Load() + if th == nil { + return "", process.ErrInvalidNetwork + } + src, dst := metadata.RawSrcAddr, metadata.RawDstAddr + if src == nil || dst == nil { + return "", process.ErrInvalidNetwork + } + return th.handleResolveProcess(src, dst), nil } - return tunHandler.handleResolveProcess(src, dst), nil - } + }) +} + +func (th *TunHandler) initHook() { + installHooks() + activeTunHandler.Store(th) } +// Swap the handler, never the hook: mihomo nil-checks DefaultSocketHook once and +// dereferences it again when the socket is created, so clearing it mid-dial +// calls a nil func value. func (th *TunHandler) removeHook() { - dialer.DefaultSocketHook = nil - process.DefaultPackageNameResolver = nil + activeTunHandler.CompareAndSwap(th, nil) } var ( @@ -133,76 +169,85 @@ var ( func handleStopTun() { tunLock.Lock() defer tunLock.Unlock() - if tunHandler != nil { - tunHandler.close() + stopTunLocked() +} + +func stopTunLocked() { + if tunHandler == nil { + return } + tunHandler.close() + tunHandler = nil } func handleStartTun(callback unsafe.Pointer, fd int, stack, address, dns string) { - handleStopTun() tunLock.Lock() defer tunLock.Unlock() - if fd != 0 { - tunHandler = &TunHandler{ - callback: callback, - limit: semaphore.NewWeighted(4), + stopTunLocked() + if fd == 0 { + if callback != nil { + releaseObject(callback) } - tunHandler.start(fd, stack, address, dns) + return } + tunHandler = &TunHandler{ + callback: callback, + } + tunHandler.start(fd, stack, address, dns) } +var ( + dnsUpdateMu sync.Mutex + dnsUpdateSeq atomic.Uint64 +) + func handleUpdateDns(value string) { - go func() { + seq := dnsUpdateSeq.Add(1) + safeGoDetached("updateDns", func() { + dnsUpdateMu.Lock() + defer dnsUpdateMu.Unlock() + if seq != dnsUpdateSeq.Load() { + return + } log.Infoln("[DNS] updateDns %s", value) dns.UpdateSystemDNS(strings.Split(value, ",")) dns.FlushCacheWithDefaultResolver() - }() + }) } func (response MethodResponse) send() { data, err := response.JSON() if err != nil { + logError("MethodResponse marshal error: id=%s err=%v", response.ID, err) + releaseObject(response.callback) return } invokeResult(response.callback, string(data)) releaseObject(response.callback) } -func handlePlatformMethodCall(call *MethodCall, response MethodResponse) bool { - switch call.Method { - case updateDnsMethod: - value := "" - if !decodeMethodArguments(call, response, &value) { - return true - } - handleUpdateDns(value) +func init() { + registerMethod(updateDnsMethod, withArguments(func(value *string, response MethodResponse) { + handleUpdateDns(*value) response.success(true) - return true - } - return false + })) } //export invokeMethod func invokeMethod(callback unsafe.Pointer, paramsChar *C.char) { params := takeCString(paramsChar) call := &MethodCall{} - err := json.Unmarshal([]byte(params), call) - if err != nil { - response := MethodResponse{callback: callback} - response.failure("invalid_method_call", err.Error(), nil) + if err := json.Unmarshal([]byte(params), call); err != nil { + newMethodResponse("", callback).failure("invalid_method_call", err.Error(), nil) return } - response := MethodResponse{ - ID: call.ID, - callback: callback, - } - go handleMethodCall(call, response) + go handleMethodCall(call, newMethodResponse(call.ID, callback)) } //export startTUN func startTUN(callback unsafe.Pointer, fd C.int, stackChar, addressChar, dnsChar *C.char) bool { handleStartTun(callback, int(fd), takeCString(stackChar), takeCString(addressChar), takeCString(dnsChar)) - if !isRunning { + if !isRunning.Load() { handleStartListener() } else { handleResetConnections() @@ -214,6 +259,12 @@ func startTUN(callback unsafe.Pointer, fd C.int, stackChar, addressChar, dnsChar func quickSetup(callback unsafe.Pointer, initParamsChar *C.char, setupParamsChar *C.char) { go func() { defer releaseObject(callback) + defer func() { + if r := recover(); r != nil { + logError("panic in quickSetup: %v\n%s", r, stackTrace()) + invokeResult(callback, fmt.Sprintf("internal panic: %v", r)) + } + }() initParamsString := takeCString(initParamsChar) setupParamsString := takeCString(setupParamsChar) initParams := InitParams{} @@ -221,20 +272,21 @@ func quickSetup(callback unsafe.Pointer, initParamsChar *C.char, setupParamsChar invokeResult(callback, "init failed") return } - isRunning = true setupParams := defaultSetupParams() if err := UnmarshalJson([]byte(setupParamsString), setupParams); err != nil { invokeResult(callback, err.Error()) return } - message := handleSetupConfig(setupParams) - invokeResult(callback, message) + isRunning.Store(true) + invokeResult(callback, handleSetupConfig(setupParams)) }() } //export setEventListener func setEventListener(listener unsafe.Pointer) { - if eventListener != nil || listener == nil { + eventListenerLock.Lock() + defer eventListenerLock.Unlock() + if eventListener != nil { releaseObject(eventListener) } eventListener = listener @@ -259,31 +311,19 @@ func marshalResult(value any) string { return string(data) } -func sendMessageBatch(messages []Message) { +func deliverEvent(data []byte) { + eventListenerLock.RLock() + defer eventListenerLock.RUnlock() if eventListener == nil { return } - arguments, err := json.Marshal(messages) - if err != nil { - logError("Message batch marshal error: %v", err) - return - } - call := MethodCall{ - Method: messageMethod, - Arguments: arguments, - } - data, err := json.Marshal(call) - if err != nil { - logError("MethodCall marshal error: method=%s err=%v", call.Method, err) - return - } invokeResult(eventListener, string(data)) } //export stopTun func stopTun() { handleStopTun() - if isRunning { + if isRunning.Load() { handleStopListener() } } diff --git a/core/main.go b/core/main.go index 61ca4e703a..c17d160840 100644 --- a/core/main.go +++ b/core/main.go @@ -1,4 +1,4 @@ -//go:build !cgo +//go:build !(android && cgo) package main @@ -10,7 +10,7 @@ import ( func main() { args := os.Args if len(args) <= 1 { - fmt.Println("Arguments error") + fmt.Fprintln(os.Stderr, "Arguments error") os.Exit(1) } startServer(args[1]) diff --git a/core/main_cgo.go b/core/main_cgo.go index d17e1f0c8d..dff63a2576 100644 --- a/core/main_cgo.go +++ b/core/main_cgo.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build android && cgo package main diff --git a/core/message.go b/core/message.go index cb3dff0a54..84f5a8d9bb 100644 --- a/core/message.go +++ b/core/message.go @@ -1,68 +1,115 @@ package main -import "time" +import ( + "encoding/json" + "time" +) const ( messageBatchInterval = 16 * time.Millisecond messageBatchSize = 32 messageQueueSize = 256 messagePriorityBurst = 8 + messageEvictAttempts = 4 ) var ( + stateMessageQueue = make(chan Message, messageQueueSize) priorityMessageQueue = make(chan Message, messageQueueSize) bulkMessageQueue = make(chan Message, messageQueueSize) ) func init() { - go runMessageBatcher(priorityMessageQueue, bulkMessageQueue, sendMessageBatch) + go runMessageBatcher(stateMessageQueue, priorityMessageQueue, bulkMessageQueue, sendMessageBatch) } -func sendMessage(message Message) { - queue := priorityMessageQueue - if message.Type == LogMessage || message.Type == RequestMessage { - queue = bulkMessageQueue - } - enqueueLatest(queue, message) -} +type messageClass int -func enqueueLatest(queue chan Message, message Message) { - select { - case queue <- message: - return +const ( + stateMessageClass messageClass = iota + priorityMessageClass + bulkMessageClass +) + +func classOfMessage(message Message) messageClass { + switch message.Type { + case LoadedMessage, GeoUpdateMessage: + return stateMessageClass + case LogMessage, RequestMessage: + return bulkMessageClass default: + return priorityMessageClass } +} - // Event delivery must never block the core. Each priority class evicts only - // its own oldest event, so log or request floods cannot displace state. - select { - case <-queue: +func sendMessage(message Message) { + switch classOfMessage(message) { + case stateMessageClass: + enqueueState(stateMessageQueue, message) + case bulkMessageClass: + enqueueLatest(bulkMessageQueue, message) default: + enqueueLatest(priorityMessageQueue, message) } +} + +func enqueueState(queue chan Message, message Message) { select { case queue <- message: default: } } +func enqueueLatest(queue chan Message, message Message) { + for attempt := 0; attempt < messageEvictAttempts; attempt++ { + select { + case queue <- message: + return + default: + } + + select { + case <-queue: + default: + } + } +} + func runMessageBatcher( + stateMessages <-chan Message, priorityMessages <-chan Message, bulkMessages <-chan Message, send func([]Message), ) { - ticker := time.NewTicker(messageBatchInterval) - defer ticker.Stop() + timer := time.NewTimer(messageBatchInterval) + if !timer.Stop() { + <-timer.C + } + defer timer.Stop() + var deadline <-chan time.Time batch := make([]Message, 0, messageBatchSize) + flush := func() { if len(batch) == 0 { return } - current := append([]Message(nil), batch...) - batch = batch[:0] + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + deadline = nil + current := batch + batch = make([]Message, 0, messageBatchSize) send(current) } appendMessage := func(message Message) { + if len(batch) == 0 { + timer.Reset(messageBatchInterval) + deadline = timer.C + } batch = append(batch, message) if len(batch) >= messageBatchSize { flush() @@ -70,7 +117,18 @@ func runMessageBatcher( } priorityBurst := 0 - for priorityMessages != nil || bulkMessages != nil { + for stateMessages != nil || priorityMessages != nil || bulkMessages != nil { + select { + case message, ok := <-stateMessages: + if !ok { + stateMessages = nil + } else { + appendMessage(message) + } + continue + default: + } + // Give bulk events one guaranteed opportunity after a bounded priority // burst, while retaining priority preference under ordinary load. if priorityBurst >= messagePriorityBurst && bulkMessages != nil { @@ -88,7 +146,6 @@ func runMessageBatcher( } } - // Prefer state-bearing events whenever both queues have work. select { case message, ok := <-priorityMessages: if !ok { @@ -102,6 +159,12 @@ func runMessageBatcher( } select { + case message, ok := <-stateMessages: + if !ok { + stateMessages = nil + } else { + appendMessage(message) + } case message, ok := <-priorityMessages: if !ok { priorityMessages = nil @@ -116,9 +179,26 @@ func runMessageBatcher( appendMessage(message) } priorityBurst = 0 - case <-ticker.C: + case <-deadline: flush() } } flush() } + +type messageBatchCall struct { + Method CoreMethod `json:"method"` + Arguments []Message `json:"arguments"` +} + +func sendMessageBatch(messages []Message) { + data, err := json.Marshal(messageBatchCall{ + Method: messageMethod, + Arguments: messages, + }) + if err != nil { + logError("Message batch marshal error: %v", err) + return + } + deliverEvent(data) +} diff --git a/core/message_test.go b/core/message_test.go new file mode 100644 index 0000000000..25f30c4795 --- /dev/null +++ b/core/message_test.go @@ -0,0 +1,279 @@ +package main + +import ( + "sync" + "testing" + "time" +) + +type batchCollector struct { + mu sync.Mutex + batches [][]Message +} + +func (collector *batchCollector) send(messages []Message) { + collector.mu.Lock() + defer collector.mu.Unlock() + collector.batches = append(collector.batches, messages) +} + +func (collector *batchCollector) snapshot() [][]Message { + collector.mu.Lock() + defer collector.mu.Unlock() + return append([][]Message(nil), collector.batches...) +} + +func (collector *batchCollector) flattened() []Message { + var messages []Message + for _, batch := range collector.snapshot() { + messages = append(messages, batch...) + } + return messages +} + +func closedMessages() chan Message { + queue := make(chan Message) + close(queue) + return queue +} + +func runBatcherUntilDrained(t *testing.T, state, priority, bulk chan Message) *batchCollector { + t.Helper() + collector := &batchCollector{} + done := make(chan struct{}) + go func() { + defer close(done) + runMessageBatcher(state, priority, bulk, collector.send) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("runMessageBatcher did not return after every queue closed") + } + return collector +} + +func TestClassOfMessageRoutesEachTier(t *testing.T) { + for messageType, want := range map[MessageType]messageClass{ + LoadedMessage: stateMessageClass, + GeoUpdateMessage: stateMessageClass, + DelayMessage: priorityMessageClass, + LogMessage: bulkMessageClass, + RequestMessage: bulkMessageClass, + } { + if got := classOfMessage(Message{Type: messageType}); got != want { + t.Errorf("classOfMessage(%s) = %d, want %d", messageType, got, want) + } + } +} + +func TestEnqueueStateNeverEvictsAQueuedEvent(t *testing.T) { + queue := make(chan Message, 2) + for i := 0; i < 5; i++ { + enqueueState(queue, Message{Type: GeoUpdateMessage, Data: i}) + } + + if len(queue) != 2 { + t.Fatalf("queue length = %d, want 2", len(queue)) + } + for _, want := range []int{0, 1} { + if got := (<-queue).Data.(int); got != want { + t.Errorf("retained Data = %d, want %d; a state event was evicted", got, want) + } + } +} + +func TestEnqueueStateNeverBlocks(t *testing.T) { + queue := make(chan Message, 1) + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 1000; i++ { + enqueueState(queue, Message{Type: LoadedMessage, Data: i}) + } + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("enqueueState blocked on a full queue") + } +} + +func TestRunMessageBatcherDrainsStateAheadOfADelayBacklog(t *testing.T) { + state := make(chan Message, 1) + priority := make(chan Message, messageBatchSize*2) + for i := 0; i < messageBatchSize*2; i++ { + priority <- Message{Type: DelayMessage, Data: i} + } + state <- Message{Type: GeoUpdateMessage, Data: "finished"} + close(state) + close(priority) + + batches := runBatcherUntilDrained(t, state, priority, closedMessages()).snapshot() + + if len(batches) == 0 { + t.Fatal("the batcher delivered nothing") + } + first := batches[0] + if len(first) == 0 || first[0].Type != GeoUpdateMessage { + t.Fatalf("first delivered message = %+v, want the geo update ahead of the delay backlog", first) + } +} + +func TestEnqueueLatestEvictsOldestOfItsOwnQueue(t *testing.T) { + queue := make(chan Message, 2) + for i := 0; i < 4; i++ { + enqueueLatest(queue, Message{Type: DelayMessage, Data: i}) + } + + if len(queue) != 2 { + t.Fatalf("queue length = %d, want 2", len(queue)) + } + for _, want := range []int{2, 3} { + got := (<-queue).Data.(int) + if got != want { + t.Errorf("retained Data = %d, want %d", got, want) + } + } +} + +func TestEnqueueLatestNeverBlocks(t *testing.T) { + queue := make(chan Message, 1) + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 1000; i++ { + enqueueLatest(queue, Message{Type: LogMessage, Data: i}) + } + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("enqueueLatest blocked on a full queue") + } +} + +func TestRunMessageBatcherPrefersPriorityAndGuaranteesBulkOpportunity(t *testing.T) { + priority := make(chan Message, 64) + bulk := make(chan Message, 64) + for i := 0; i < 20; i++ { + priority <- Message{Type: DelayMessage, Data: i} + } + for i := 0; i < 5; i++ { + bulk <- Message{Type: LogMessage, Data: i} + } + close(priority) + close(bulk) + + got := runBatcherUntilDrained(t, closedMessages(), priority, bulk).flattened() + + want := make([]Message, 0, 25) + appendPriority := func(from, to int) { + for i := from; i < to; i++ { + want = append(want, Message{Type: DelayMessage, Data: i}) + } + } + appendPriority(0, 8) + want = append(want, Message{Type: LogMessage, Data: 0}) + appendPriority(8, 16) + want = append(want, Message{Type: LogMessage, Data: 1}) + appendPriority(16, 20) + want = append(want, + Message{Type: LogMessage, Data: 2}, + Message{Type: LogMessage, Data: 3}, + Message{Type: LogMessage, Data: 4}, + ) + + if len(got) != len(want) { + t.Fatalf("delivered %d messages, want %d", len(got), len(want)) + } + for i := range want { + if got[i].Type != want[i].Type || got[i].Data != want[i].Data { + t.Fatalf( + "message %d = {%s %v}, want {%s %v}", + i, got[i].Type, got[i].Data, want[i].Type, want[i].Data, + ) + } + } +} + +func TestRunMessageBatcherFlushesAtBatchSize(t *testing.T) { + total := messageBatchSize*2 + 6 + priority := make(chan Message, total) + bulk := make(chan Message) + for i := 0; i < total; i++ { + priority <- Message{Type: DelayMessage, Data: i} + } + close(priority) + close(bulk) + + batches := runBatcherUntilDrained(t, closedMessages(), priority, bulk).snapshot() + + wantSizes := []int{messageBatchSize, messageBatchSize, 6} + if len(batches) != len(wantSizes) { + t.Fatalf("got %d batches, want %d", len(batches), len(wantSizes)) + } + for i, wantSize := range wantSizes { + if len(batches[i]) != wantSize { + t.Errorf("batch %d size = %d, want %d", i, len(batches[i]), wantSize) + } + } +} + +func TestRunMessageBatcherFlushesOnInterval(t *testing.T) { + priority := make(chan Message, 4) + bulk := make(chan Message, 4) + collector := &batchCollector{} + done := make(chan struct{}) + go func() { + defer close(done) + runMessageBatcher(closedMessages(), priority, bulk, collector.send) + }() + + for i := 0; i < 3; i++ { + priority <- Message{Type: DelayMessage, Data: i} + } + + deadline := time.After(2 * time.Second) + for { + if len(collector.flattened()) == 3 { + break + } + select { + case <-deadline: + t.Fatal("batcher did not flush a partial batch on the interval tick") + case <-time.After(5 * time.Millisecond): + } + } + + close(priority) + close(bulk) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("runMessageBatcher did not return after every queue closed") + } +} + +func TestRunMessageBatcherDrainsBulkAfterPriorityCloses(t *testing.T) { + priority := make(chan Message) + bulk := make(chan Message, 4) + for i := 0; i < 4; i++ { + bulk <- Message{Type: RequestMessage, Data: i} + } + close(priority) + close(bulk) + + got := runBatcherUntilDrained(t, closedMessages(), priority, bulk).flattened() + + if len(got) != 4 { + t.Fatalf("delivered %d bulk messages, want 4", len(got)) + } + for i, message := range got { + if message.Type != RequestMessage || message.Data != i { + t.Errorf("message %d = {%s %v}, want {request %d}", i, message.Type, message.Data, i) + } + } +} diff --git a/core/method.go b/core/method.go index 62c4c3abaf..5421989fc9 100644 --- a/core/method.go +++ b/core/method.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "runtime" + "sync/atomic" "unsafe" ) @@ -43,19 +44,41 @@ type MethodResponse struct { Result any `json:"result"` Error *MethodError `json:"error,omitempty"` callback unsafe.Pointer + sent *atomic.Bool +} + +func newMethodResponse(id string, callback unsafe.Pointer) MethodResponse { + return MethodResponse{ + ID: id, + callback: callback, + sent: &atomic.Bool{}, + } } func (response MethodResponse) JSON() ([]byte, error) { return json.Marshal(response) } +func (response MethodResponse) claim() bool { + if response.sent == nil { + return true + } + return response.sent.CompareAndSwap(false, true) +} + func (response MethodResponse) success(result any) { + if !response.claim() { + return + } response.Result = result response.Error = nil response.send() } func (response MethodResponse) failure(code, message string, details any) { + if !response.claim() { + return + } response.Result = nil response.Error = &MethodError{ Code: code, @@ -73,204 +96,202 @@ func (response MethodResponse) notImplemented(method CoreMethod) { ) } -func handleMethodCall(call *MethodCall, response MethodResponse) { - // The crash method is a developer-only fatal-path test. It must bypass the - // recovery below so the core process terminates; on Android this also - // terminates the in-process application. - if call.Method == crashMethod { - handleCrash() - return - } - defer func() { - if r := recover(); r != nil { - buf := make([]byte, 4096) - n := runtime.Stack(buf, false) - logError("panic in handleMethodCall(%s): %v\n%s", call.Method, r, buf[:n]) - response.failure("internal_error", fmt.Sprintf("internal panic: %v", r), nil) - } +func stackTrace() []byte { + buf := make([]byte, 4096) + return buf[:runtime.Stack(buf, false)] +} + +func safeGo(response MethodResponse, run func()) { + go func() { + defer func() { + if r := recover(); r != nil { + logError("panic in async handler: %v\n%s", r, stackTrace()) + response.failure("internal_error", fmt.Sprintf("internal panic: %v", r), nil) + } + }() + run() }() - switch call.Method { - case initClashMethod: - params := InitParams{} +} + +func safeGoDetached(name string, run func()) { + go func() { + defer func() { + if r := recover(); r != nil { + logError("panic in %s: %v\n%s", name, r, stackTrace()) + } + }() + run() + }() +} + +type methodHandler func(call *MethodCall, response MethodResponse) + +func withArguments[T any](handle func(params *T, response MethodResponse)) methodHandler { + return func(call *MethodCall, response MethodResponse) { + var params T if !decodeMethodArguments(call, response, ¶ms) { return } - response.success(handleInitClash(¶ms)) - return - case getIsInitMethod: + handle(¶ms, response) + } +} + +func withDefaults[T any]( + defaults func() *T, + handle func(params *T, response MethodResponse), +) methodHandler { + return func(call *MethodCall, response MethodResponse) { + params := defaults() + if !decodeMethodArguments(call, response, params) { + return + } + handle(params, response) + } +} + +func withoutArguments(handle func(response MethodResponse)) methodHandler { + return func(_ *MethodCall, response MethodResponse) { + handle(response) + } +} + +var methodHandlers = map[CoreMethod]methodHandler{ + initClashMethod: withArguments(func(params *InitParams, response MethodResponse) { + response.success(handleInitClash(params)) + }), + getIsInitMethod: withoutArguments(func(response MethodResponse) { response.success(handleGetIsInit()) - return - case forceGcMethod: + }), + forceGcMethod: withoutArguments(func(response MethodResponse) { handleForceGC() response.success(true) - return - case shutdownMethod: + }), + shutdownMethod: withoutArguments(func(response MethodResponse) { response.success(handleShutdown()) - return - case validateConfigMethod: - path := "" - if !decodeMethodArguments(call, response, &path) { - return - } - response.success(handleValidateConfig(path)) - return - case updateConfigMethod: - params := UpdateParams{} - if !decodeMethodArguments(call, response, ¶ms) { - return - } - response.success(handleUpdateConfig(¶ms)) - return - case setupConfigMethod: - params := defaultSetupParams() - if !decodeMethodArguments(call, response, params) { - return - } + }), + validateConfigMethod: withArguments(func(path *string, response MethodResponse) { + response.success(handleValidateConfig(*path)) + }), + updateConfigMethod: withArguments(func(params *UpdateParams, response MethodResponse) { + response.success(handleUpdateConfig(params)) + }), + setupConfigMethod: withDefaults(defaultSetupParams, func(params *SetupParams, response MethodResponse) { response.success(handleSetupConfig(params)) - return - case getProxiesMethod: - response.success(handleGetProxies()) - return - case changeProxyMethod: - params := ChangeProxyParams{} - if !decodeMethodArguments(call, response, ¶ms) { + }), + getConfigMethod: withArguments(func(path *string, response MethodResponse) { + rawConfig, err := handleGetConfig(*path) + if err != nil { + response.failure("core_error", err.Error(), nil) return } - handleChangeProxy(¶ms, func(value string) { - response.success(value) + response.success(rawConfig) + }), + getProxiesMethod: withoutArguments(func(response MethodResponse) { + response.success(handleGetProxies()) + }), + changeProxyMethod: withArguments(func(params *ChangeProxyParams, response MethodResponse) { + safeGo(response, func() { + response.success(handleChangeProxy(params)) }) - return - case getTrafficMethod: - onlyStatisticsProxy := false - if !decodeMethodArguments(call, response, &onlyStatisticsProxy) { - return - } - response.success(handleGetTraffic(onlyStatisticsProxy)) - return - case getTotalTrafficMethod: - onlyStatisticsProxy := false - if !decodeMethodArguments(call, response, &onlyStatisticsProxy) { - return - } - response.success(handleGetTotalTraffic(onlyStatisticsProxy)) - return - case resetTrafficMethod: + }), + getTrafficMethod: withArguments(func(onlyStatisticsProxy *bool, response MethodResponse) { + response.success(handleGetTraffic(*onlyStatisticsProxy)) + }), + getTotalTrafficMethod: withArguments(func(onlyStatisticsProxy *bool, response MethodResponse) { + response.success(handleGetTotalTraffic(*onlyStatisticsProxy)) + }), + resetTrafficMethod: withoutArguments(func(response MethodResponse) { handleResetTraffic() response.success(true) - return - case asyncTestDelayMethod: - params := TestDelayParams{} - if !decodeMethodArguments(call, response, ¶ms) { - return - } - handleAsyncTestDelay(¶ms, func(value *Delay) { - response.success(value) + }), + asyncTestDelayMethod: withArguments(func(params *TestDelayParams, response MethodResponse) { + safeGo(response, func() { + response.success(handleTestDelay(params)) }) - return - case getConnectionsMethod: + }), + getConnectionsMethod: withoutArguments(func(response MethodResponse) { response.success(handleGetConnections()) - return - case closeConnectionsMethod: + }), + closeConnectionsMethod: withoutArguments(func(response MethodResponse) { response.success(handleCloseConnections()) - return - case resetConnectionsMethod: + }), + resetConnectionsMethod: withoutArguments(func(response MethodResponse) { response.success(handleResetConnections()) - return - case getConfigMethod: - path := "" - if !decodeMethodArguments(call, response, &path) { - return - } - config, err := handleGetConfig(path) - if err != nil { - response.failure("core_error", err.Error(), nil) - return - } - response.success(config) - return - case closeConnectionMethod: - id := "" - if !decodeMethodArguments(call, response, &id) { - return - } - response.success(handleCloseConnection(id)) - return - case getExternalProvidersMethod: + }), + closeConnectionMethod: withArguments(func(id *string, response MethodResponse) { + response.success(handleCloseConnection(*id)) + }), + getExternalProvidersMethod: withoutArguments(func(response MethodResponse) { response.success(handleGetExternalProviders()) - return - case getExternalProviderMethod: - externalProviderName := "" - if !decodeMethodArguments(call, response, &externalProviderName) { - return - } - response.success(handleGetExternalProvider(externalProviderName)) - return - case updateGeoDataMethod: - geoType := "" - if !decodeMethodArguments(call, response, &geoType) { - return - } - handleUpdateGeoData(geoType) - response.success("") - return - case updateExternalProviderMethod: - providerName := "" - if !decodeMethodArguments(call, response, &providerName) { - return - } - handleUpdateExternalProvider(providerName, func(value string) { - response.success(value) + }), + getExternalProviderMethod: withArguments(func(name *string, response MethodResponse) { + response.success(handleGetExternalProvider(*name)) + }), + updateExternalProviderMethod: withArguments(func(name *string, response MethodResponse) { + safeGo(response, func() { + response.success(handleUpdateExternalProvider(*name)) }) - return - case sideLoadExternalProviderMethod: - params := map[string]string{} - if !decodeMethodArguments(call, response, ¶ms) { - return - } - providerName := params["providerName"] - data := params["data"] - handleSideLoadExternalProvider(providerName, []byte(data), func(value string) { - response.success(value) + }), + sideLoadExternalProviderMethod: withArguments(func(params *SideLoadParams, response MethodResponse) { + safeGo(response, func() { + response.success(handleSideLoadExternalProvider(params.ProviderName, []byte(params.Data))) }) - return - case startLogMethod: + }), + updateGeoDataMethod: withArguments(func(geoType *string, response MethodResponse) { + response.success(handleUpdateGeoData(*geoType)) + }), + startLogMethod: withoutArguments(func(response MethodResponse) { handleStartLog() response.success(true) - return - case stopLogMethod: + }), + stopLogMethod: withoutArguments(func(response MethodResponse) { handleStopLog() response.success(true) - return - case startListenerMethod: + }), + startListenerMethod: withoutArguments(func(response MethodResponse) { response.success(handleStartListener()) - return - case stopListenerMethod: + }), + stopListenerMethod: withoutArguments(func(response MethodResponse) { response.success(handleStopListener()) - return - case getCountryCodeMethod: - ip := "" - if !decodeMethodArguments(call, response, &ip) { - return - } - handleGetCountryCode(ip, func(value string) { - response.success(value) + }), + getMemoryMethod: withoutArguments(func(response MethodResponse) { + safeGo(response, func() { + response.success(handleGetMemory()) }) - return - case getMemoryMethod: - handleGetMemory(func(value uint64) { - response.success(value) + }), + clearEffectMethod: withArguments(func(profileId *int64, response MethodResponse) { + safeGo(response, func() { + response.success(handleClearEffect(*profileId)) }) + }), +} + +func registerMethod(method CoreMethod, handler methodHandler) { + if _, exists := methodHandlers[method]; exists { + panic(fmt.Sprintf("duplicate handler for method %s", method)) + } + methodHandlers[method] = handler +} + +func handleMethodCall(call *MethodCall, response MethodResponse) { + // The crash method is a developer-only fatal-path test. It must bypass the + // recovery below so the core process terminates; on Android this also + // terminates the in-process application. + if call.Method == crashMethod { + handleCrash() return - case clearEffectMethod: - var profileId int64 - if !decodeMethodArguments(call, response, &profileId) { - return + } + defer func() { + if r := recover(); r != nil { + logError("panic in handleMethodCall(%s): %v\n%s", call.Method, r, stackTrace()) + response.failure("internal_error", fmt.Sprintf("internal panic: %v", r), nil) } - handleClearEffect(profileId, response) + }() + + handler, exists := methodHandlers[call.Method] + if !exists { + response.notImplemented(call.Method) return - default: - if !handlePlatformMethodCall(call, response) { - response.notImplemented(call.Method) - } } + handler(call, response) } diff --git a/core/platform/limit.go b/core/platform/limit.go index 572357e22d..c712620f74 100644 --- a/core/platform/limit.go +++ b/core/platform/limit.go @@ -2,15 +2,33 @@ package platform -import "syscall" +import ( + "sync/atomic" + "syscall" + "time" -var nullFd int + "github.com/metacubex/mihomo/log" +) + +const fdPressureWindow = 10 * time.Millisecond + +var nullFd = -1 var maxFdCount int +var ( + lastProbeAt atomic.Int64 + lastProbeBlocked atomic.Bool +) + func init() { + // This runs while the shared library is being loaded, so a panic here takes + // the application down before it has a chance to report anything. The probe + // is a safety valve against fd exhaustion rather than something correctness + // depends on, so a failure to arm it degrades to never blocking. fd, err := syscall.Open("/dev/null", syscall.O_WRONLY, 0644) if err != nil { - panic(err.Error()) + log.Errorln("[APP] fd pressure probe disabled: %v", err) + return } nullFd = fd @@ -27,6 +45,24 @@ func init() { } func ShouldBlockConnection() bool { + if nullFd < 0 { + return false + } + + now := time.Now().UnixNano() + if !lastProbeBlocked.Load() { + if last := lastProbeAt.Load(); last != 0 && now-last < int64(fdPressureWindow) { + return false + } + } + + blocked := probeFdPressure() + lastProbeBlocked.Store(blocked) + lastProbeAt.Store(now) + return blocked +} + +func probeFdPressure() bool { fd, err := syscall.Dup(nullFd) if err != nil { return true @@ -34,9 +70,5 @@ func ShouldBlockConnection() bool { _ = syscall.Close(fd) - if fd > maxFdCount { - return true - } - - return false + return fd > maxFdCount } diff --git a/core/platform/procfs.go b/core/platform/procfs.go index a6d604be00..1fbbc937bb 100644 --- a/core/platform/procfs.go +++ b/core/platform/procfs.go @@ -4,9 +4,9 @@ package platform import ( "bufio" + "bytes" "encoding/binary" "encoding/hex" - "fmt" "net" "os" "strconv" @@ -63,6 +63,40 @@ func QuerySocketUidFromProcFs(source, _ net.Addr) int { return uid } +func localAddressColumn(sIP net.IP, sPort int) []byte { + ip := nativeEndianIP(sIP) + column := make([]byte, 0, hex.EncodedLen(len(ip))+1+4) + + encoded := make([]byte, hex.EncodedLen(len(ip))) + hex.Encode(encoded, ip) + column = append(column, encoded...) + + column = append(column, ':') + + var port [2]byte + binary.BigEndian.PutUint16(port[:], uint16(sPort)) + var encodedPort [4]byte + hex.Encode(encodedPort[:], port[:]) + return append(column, encodedPort[:]...) +} + +func column(row []byte, index int) []byte { + for i := 0; ; i++ { + row = bytes.TrimLeft(row, " \t") + if len(row) == 0 { + return nil + } + end := bytes.IndexAny(row, " \t") + if end < 0 { + end = len(row) + } + if i == index { + return row[:end] + } + row = row[end:] + } +} + func doQuery(path string, sIP net.IP, sPort int) int { file, err := os.Open(path) if err != nil { @@ -73,35 +107,30 @@ func doQuery(path string, sIP net.IP, sPort int) int { _ = file.Close() }(file) - reader := bufio.NewReader(file) + local := localAddressColumn(sIP, sPort) - var bytes [2]byte + scanner := bufio.NewScanner(file) + for scanner.Scan() { + row := scanner.Bytes() - binary.BigEndian.PutUint16(bytes[:], uint16(sPort)) - - local := fmt.Sprintf("%s:%s", hex.EncodeToString(nativeEndianIP(sIP)), hex.EncodeToString(bytes[:])) + if !bytes.EqualFold(local, column(row, netIndexOfLocal)) { + continue + } - for { - row, _, err := reader.ReadLine() - if err != nil { + uidColumn := column(row, netIndexOfUid) + if uidColumn == nil { return -1 } - fields := strings.Fields(string(row)) - - if len(fields) <= netIndexOfLocal || len(fields) <= netIndexOfUid { - continue + uid, err := strconv.Atoi(string(uidColumn)) + if err != nil { + return -1 } - if strings.EqualFold(local, fields[netIndexOfLocal]) { - uid, err := strconv.Atoi(fields[netIndexOfUid]) - if err != nil { - return -1 - } - - return uid - } + return uid } + + return -1 } func nativeEndianIP(ip net.IP) []byte { diff --git a/core/server.go b/core/server.go index 28b8cb6135..6392263178 100644 --- a/core/server.go +++ b/core/server.go @@ -1,4 +1,4 @@ -//go:build !cgo +//go:build !(android && cgo) package main @@ -7,15 +7,39 @@ import ( "encoding/json" "fmt" "io" + "os" "sync" + "sync/atomic" + "time" ) +type ipcConn interface { + io.ReadWriteCloser + SetWriteDeadline(t time.Time) error +} + var ( - conn io.ReadWriteCloser - connMu sync.Mutex + conn ipcConn + connMu sync.Mutex + writeMu sync.Mutex ) -const maxIPCFrameSize = 64 * 1024 * 1024 +const ( + maxIPCFrameSize = 64 * 1024 * 1024 + ipcWriteTimeout = 10 * time.Second +) + +var deliveryFailureReported atomic.Bool + +// logDeliveryError must not reach the mihomo logger: a log event is published +// to the log subscriber, batched, and handed back to send, so reporting a send +// failure through it feeds the failure straight back into itself. +func logDeliveryError(format string, args ...any) { + if deliveryFailureReported.Swap(true) { + return + } + fmt.Fprintf(os.Stderr, "[ERROR] "+format+"\n", args...) +} func (response MethodResponse) send() { data, err := response.JSON() @@ -26,48 +50,38 @@ func (response MethodResponse) send() { send(data) } -func sendMessageBatch(messages []Message) { - arguments, err := json.Marshal(messages) - if err != nil { - logError("Message batch marshal error: %v", err) - return - } - call := MethodCall{ - Method: messageMethod, - Arguments: arguments, - } - data, err := json.Marshal(call) - if err != nil { - logError("MethodCall marshal error: method=%s err=%v", call.Method, err) - return - } +func deliverEvent(data []byte) { send(data) } -func writeFrame(w io.Writer, data []byte) error { +func writeFrame(w io.Writer, data []byte) (int, error) { if len(data) > maxIPCFrameSize { - return fmt.Errorf("IPC frame exceeds %d bytes", maxIPCFrameSize) + return 0, fmt.Errorf("IPC frame exceeds %d bytes", maxIPCFrameSize) } lenBuf := [4]byte{} binary.LittleEndian.PutUint32(lenBuf[:], uint32(len(data))) - if err := writeAll(w, lenBuf[:]); err != nil { - return err + written, err := writeAll(w, lenBuf[:]) + if err != nil { + return written, err } - return writeAll(w, data) + n, err := writeAll(w, data) + return written + n, err } -func writeAll(w io.Writer, data []byte) error { +func writeAll(w io.Writer, data []byte) (int, error) { + written := 0 for len(data) > 0 { n, err := w.Write(data) + written += n if err != nil { - return err + return written, err } if n == 0 { - return io.ErrShortWrite + return written, io.ErrShortWrite } data = data[n:] } - return nil + return written, nil } func readFrame(r io.Reader) ([]byte, error) { @@ -87,53 +101,73 @@ func readFrame(r io.Reader) ([]byte, error) { } func send(data []byte) { - if conn == nil { - logError("send conn nil") + writeMu.Lock() + defer writeMu.Unlock() + + connMu.Lock() + c := conn + connMu.Unlock() + + if c == nil { + logDeliveryError("send conn nil") + return + } + if err := c.SetWriteDeadline(time.Now().Add(ipcWriteTimeout)); err != nil { + logDeliveryError("server write deadline error: %v", err) + } + written, err := writeFrame(c, data) + if err == nil { + deliveryFailureReported.Store(false) return } + if written == 0 { + logDeliveryError("server write error, dropped one frame: %v", err) + return + } + logDeliveryError("server write error after %d bytes: %v", written, err) connMu.Lock() - defer connMu.Unlock() - if err := writeFrame(conn, data); err != nil { - logError("server write error: %v", err) + if conn == c { + conn = nil } + connMu.Unlock() + _ = c.Close() } func startServer(arg string) { - var err error - conn, err = dial(arg) + dialed, err := dial(arg) if err != nil { panic(err.Error()) } + defer func() { + connMu.Lock() + c := conn + conn = nil + connMu.Unlock() + if c != nil { + _ = c.Close() + } + }() - defer func(conn io.Closer) { - _ = conn.Close() - }(conn) + connMu.Lock() + conn = dialed + deliveryFailureReported.Store(false) + connMu.Unlock() for { - data, err := readFrame(conn) + data, err := readFrame(dialed) if err != nil { if err != io.EOF { logError("server read error: %v", err) } return } - call := &MethodCall{} - - err = json.Unmarshal(data, call) - if err != nil { + call := &MethodCall{} + if err := json.Unmarshal(data, call); err != nil { logError("server unmarshal error: %v (data: %q)", err, data) continue } - response := MethodResponse{ - ID: call.ID, - } - - go handleMethodCall(call, response) + go handleMethodCall(call, newMethodResponse(call.ID, nil)) } } - -func handlePlatformMethodCall(call *MethodCall, response MethodResponse) bool { - return false -} diff --git a/core/tun/tun.go b/core/tun/tun.go index 5d5d1d0907..6498617e18 100644 --- a/core/tun/tun.go +++ b/core/tun/tun.go @@ -28,7 +28,7 @@ func Start(fd int, stack string, address, dns string) *sing_tun.Listener { } prefix, err := netip.ParsePrefix(a) if err != nil { - log.Errorln("TUN:", err) + log.Errorln("TUN: %v", err) return nil } if prefix.Addr().Is4() { @@ -63,7 +63,7 @@ func Start(fd int, stack string, address, dns string) *sing_tun.Listener { listener, err := sing_tun.New(options, tunnel.Tunnel) if err != nil { - log.Errorln("TUN:", err) + log.Errorln("TUN: %v", err) return nil } diff --git a/lib/application.dart b/lib/application.dart index 2bd4829327..70ba9a39ac 100644 --- a/lib/application.dart +++ b/lib/application.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/bootstrap.dart'; +import 'package:fl_clash/common/system_dns.dart'; import 'package:fl_clash/l10n/l10n.dart'; import 'package:fl_clash/manager/hotkey_manager.dart'; import 'package:fl_clash/manager/manager.dart'; @@ -13,9 +15,37 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; - import 'pages/pages.dart'; +Widget buildManagerStack({ + required bool isDesktop, + required Future Function(List results) + onConnectivityChanged, + required Widget child, +}) { + final platformApp = isDesktop + ? WindowHeaderContainer(child: child) + : VpnManager(child: child); + final state = AppStateManager( + child: CoreManager( + child: ConnectivityManager( + onConnectivityChanged: onConnectivityChanged, + child: platformApp, + ), + ), + ); + final platformState = isDesktop + ? WindowManager( + child: TrayManager( + child: HotKeyManager(child: ProxyManager(child: state)), + ), + ) + : AndroidManager(child: TileManager(child: state)); + return AppEnvManager( + child: StatusManager(child: ThemeManager(child: platformState)), + ); +} + class Application extends ConsumerStatefulWidget { const Application({super.key}); @@ -36,10 +66,7 @@ class ApplicationState extends ConsumerState { }, ); - ColorScheme _getAppColorScheme({ - required Brightness brightness, - int? primaryColor, - }) { + ColorScheme _getAppColorScheme({required Brightness brightness}) { return ref.read(genColorSchemeProvider(brightness)); } @@ -49,19 +76,19 @@ class ApplicationState extends ConsumerState { SystemNavigator.setFrameworkHandlesBack(true); WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { if (globalState.navigatorKey.currentContext != null) { - await globalState.attach(); + await bootstrap.attach(); } else { exit(0); } _autoUpdateProfilesTask(); _initLink(); - app?.initShortcuts(); + unawaited(app?.initShortcuts()); }); } void _initLink() { linkManager.initAppLinksListen((url) async { - final res = await globalState.showMessage( + final res = await dialogs.showMessage( title: currentAppLocalizations.addProfile, message: TextSpan( children: [ @@ -79,56 +106,33 @@ class ApplicationState extends ConsumerState { ), ); if (res != true) return; - ref.read(profilesActionProvider.notifier).addProfileFormURL(url); + unawaited( + ref.read(profilesActionProvider.notifier).addProfileFormURL(url), + ); }); } void _autoUpdateProfilesTask() { _autoUpdateProfilesTaskTimer = Timer(const Duration(minutes: 20), () async { await ref.read(profilesActionProvider.notifier).autoUpdateProfiles(); + if (!mounted) { + return; + } _autoUpdateProfilesTask(); }); } - Widget _buildPlatformState({required Widget child}) { - if (system.isDesktop) { - return WindowManager( - child: TrayManager( - child: HotKeyManager(child: ProxyManager(child: child)), - ), - ); + Future _handleConnectivityChanged( + List results, + ) async { + commonPrint.log('connectivityChanged ${results.toString()}'); + unawaited(systemDnsCoordinator?.resync() ?? Future.value()); + unawaited(ref.read(systemActionProvider.notifier).updateLocalIp()); + final hasVpn = results.contains(ConnectivityResult.vpn); + if (_preHasVpn == hasVpn) { + ref.read(checkIpNumProvider.notifier).add(); } - return AndroidManager(child: TileManager(child: child)); - } - - Widget _buildState({required Widget child}) { - return AppStateManager( - child: CoreManager( - child: ConnectivityManager( - onConnectivityChanged: (results) async { - commonPrint.log('connectivityChanged ${results.toString()}'); - ref.read(systemActionProvider.notifier).updateLocalIp(); - final hasVpn = results.contains(ConnectivityResult.vpn); - if (_preHasVpn == hasVpn) { - ref.read(checkIpNumProvider.notifier).add(); - } - _preHasVpn = hasVpn; - }, - child: child, - ), - ), - ); - } - - Widget _buildPlatformApp({required Widget child}) { - if (system.isDesktop) { - return WindowHeaderContainer(child: child); - } - return VpnManager(child: child); - } - - Widget _buildApp({required Widget child}) { - return StatusManager(child: ThemeManager(child: child)); + _preHasVpn = hasVpn; } @override @@ -150,33 +154,27 @@ class ApplicationState extends ConsumerState { GlobalWidgetsLocalizations.delegate, ], builder: (_, child) { - return AppEnvManager( - child: _buildApp( - child: _buildPlatformState( - child: _buildState(child: _buildPlatformApp(child: child!)), - ), - ), + return buildManagerStack( + isDesktop: system.isDesktop, + onConnectivityChanged: _handleConnectivityChanged, + child: child!, ); }, scrollBehavior: BaseScrollBehavior(), title: appName, - locale: utils.getLocaleForString(locale), + locale: getLocaleForString(locale), supportedLocales: AppLocalizations.delegate.supportedLocales, themeMode: themeProps.themeMode, theme: ThemeData( useMaterial3: true, pageTransitionsTheme: _pageTransitionsTheme, - colorScheme: _getAppColorScheme( - brightness: Brightness.light, - primaryColor: themeProps.primaryColor, - ), + colorScheme: _getAppColorScheme(brightness: Brightness.light), ), darkTheme: ThemeData( useMaterial3: true, pageTransitionsTheme: _pageTransitionsTheme, colorScheme: _getAppColorScheme( brightness: Brightness.dark, - primaryColor: themeProps.primaryColor, ).toPureBlack(themeProps.pureBlack), ), home: child!, diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart new file mode 100644 index 0000000000..e01fefa426 --- /dev/null +++ b/lib/bootstrap.dart @@ -0,0 +1,214 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/common/launch.dart'; +import 'package:fl_clash/common/migration.dart'; +import 'package:fl_clash/common/permission.dart'; +import 'package:fl_clash/common/tray.dart'; +import 'package:fl_clash/common/window.dart'; +import 'package:fl_clash/database/database.dart'; +import 'package:fl_clash/enum/enum.dart'; +import 'package:fl_clash/l10n/l10n.dart'; +import 'package:fl_clash/models/models.dart'; +import 'package:fl_clash/providers/providers.dart'; +import 'package:fl_clash/state.dart'; +import 'package:fl_clash/views/navigation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_color_utilities/palettes/core_palette.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class Bootstrap { + static Bootstrap? _instance; + + Bootstrap._internal(); + + factory Bootstrap() { + _instance ??= Bootstrap._internal(); + return _instance!; + } + + bool _didCrashOnPreviousExecution = false; + + Future init(int version) async { + globalState.appEnv = const String.fromEnvironment( + 'APP_ENV', + defaultValue: 'pre', + ); + windowPort = window; + trayPort = appTray; + navigationPort = navigation; + final dynamicColor = await _initDynamicColor(); + return _initData(version, dynamicColor); + } + + Future _initDynamicColor() async { + // ignore: deprecated_member_use + CorePalette? corePalette; + Color? accentColor; + try { + corePalette = await DynamicColorPlugin.getCorePalette(); + } catch (error) { + commonPrint.log( + 'Failed to get core palette: $error', + logLevel: LogLevel.warning, + ); + } + try { + accentColor = await DynamicColorPlugin.getAccentColor(); + } catch (error) { + commonPrint.log( + 'Failed to get accent color: $error', + logLevel: LogLevel.warning, + ); + } + return ( + lightSeed: corePalette?.toColorScheme().primary, + darkSeed: corePalette?.toColorScheme(brightness: Brightness.dark).primary, + accentColor: accentColor ?? const Color(defaultPrimaryColor), + ); + } + + Future _initData( + int version, + DynamicColorSeeds dynamicColor, + ) async { + globalState.packageInfo = await PackageInfo.fromPlatform(); + var config = await migration.run(); + _didCrashOnPreviousExecution = await system.didCrashOnPreviousExecution(); + if (_didCrashOnPreviousExecution) { + config = config.copyWith(currentProfileId: null); + await preferences.saveConfig(config); + } + final appState = AppState( + brightness: WidgetsBinding.instance.platformDispatcher.platformBrightness, + version: version, + viewSize: Size.zero, + requests: FixedList(maxLength), + logs: FixedList(maxLength), + traffics: FixedList(trafficSampleLength), + totalTraffic: const Traffic(), + systemUiOverlayStyle: const SystemUiOverlayStyle(), + ); + final appStateOverrides = buildAppStateOverrides(appState); + final configOverrides = buildConfigOverrides(config); + final container = ProviderContainer( + overrides: [...appStateOverrides, ...configOverrides], + ); + globalState.container = container; + container + .read(dynamicColorProvider.notifier) + .seed( + lightSeed: dynamicColor.lightSeed, + darkSeed: dynamicColor.darkSeed, + accentColor: dynamicColor.accentColor, + ); + final profiles = await database.profilesDao.query().get(); + container.read(profilesProvider.notifier).setAndReorder(profiles); + await AppLocalizations.load( + getLocaleForString(config.appSettingProps.locale) ?? + WidgetsBinding.instance.platformDispatcher.locale, + ); + await window?.init(version, config.windowProps); + if (system.isAndroid) { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } + return container; + } + + Future attach() async { + if (globalState.isAttach == true) { + return; + } + await _initApp(); + globalState.isAttach = true; + } + + ProviderContainer get _container => globalState.container; + + Future _initApp() async { + unawaited(_container.read(systemActionProvider.notifier).updateTray()); + unawaited( + _container.read(profilesActionProvider.notifier).autoUpdateProfiles(), + ); + unawaited(_container.read(commonActionProvider.notifier).autoCheckUpdate()); + unawaited( + autoLaunch?.updateStatus(_container.read(appSettingProvider).autoLaunch), + ); + if (!_container.read(appSettingProvider).silentLaunch) { + unawaited(window?.show()); + } else { + unawaited(window?.hide()); + } + await _handleFailedPreference(); + await _handlerDisclaimer(); + await _showCrashRecoveryTip(); + await _showCrashlyticsTip(); + await _container.read(coreActionProvider.notifier).startCore(); + if (!_didCrashOnPreviousExecution) { + await _container.read(setupActionProvider.notifier).initStatus(); + } + _container.read(initProvider.notifier).value = true; + permissions.check(_container.read); + } + + Future _showCrashRecoveryTip() async { + if (!_didCrashOnPreviousExecution) return; + await dialogs.showMessage( + title: currentAppLocalizations.crashDetected, + cancelable: false, + dismissible: false, + message: TextSpan(text: currentAppLocalizations.crashDetectedTip), + ); + } + + Future _handleFailedPreference() async { + if (await preferences.isInit) return; + final res = await dialogs.showMessage( + title: currentAppLocalizations.tip, + message: TextSpan(text: currentAppLocalizations.cacheCorrupt), + ); + if (res == true) { + final file = File(await appPath.sharedPreferencesPath); + await file.safeDelete(); + } + await _container.read(systemActionProvider.notifier).handleExit(); + } + + Future _showCrashlyticsTip() async { + if (!system.isAndroid) return; + if (_container.read( + appSettingProvider.select((state) => state.crashlyticsTip), + )) { + return; + } + await dialogs.showMessage( + title: currentAppLocalizations.dataCollectionTip, + cancelable: false, + message: TextSpan(text: currentAppLocalizations.dataCollectionContent), + ); + _container + .read(appSettingProvider.notifier) + .update((state) => state.copyWith(crashlyticsTip: true)); + } + + Future _handlerDisclaimer() async { + if (_container.read( + appSettingProvider.select((state) => state.disclaimerAccepted), + )) { + return; + } + final isDisclaimerAccepted = await dialogs.showDisclaimer(); + if (!isDisclaimerAccepted) { + await _container.read(systemActionProvider.notifier).handleExit(); + } + _container + .read(appSettingProvider.notifier) + .update((state) => state.copyWith(disclaimerAccepted: true)); + } +} + +final bootstrap = Bootstrap(); diff --git a/lib/common/app_localizations.dart b/lib/common/app_localizations.dart index bf2f519a69..718fdbda4b 100644 --- a/lib/common/app_localizations.dart +++ b/lib/common/app_localizations.dart @@ -1,3 +1,33 @@ +import 'package:dio/dio.dart'; import 'package:fl_clash/l10n/l10n.dart'; +import 'dart:ui'; final currentAppLocalizations = AppLocalizations.current; + +String? networkErrorMessage(Object error, AppLocalizations appLocalizations) { + if (error is DioException) { + return error.type == DioExceptionType.badResponse + ? appLocalizations.networkException + : appLocalizations.unknownNetworkError; + } + return null; +} + +Locale? getLocaleForString(String? localString) { + if (localString == null) return null; + final localSplit = localString.split('_'); + if (localSplit.length == 1) { + return Locale(localSplit[0]); + } + if (localSplit.length == 2) { + return Locale(localSplit[0], localSplit[1]); + } + if (localSplit.length == 3) { + return Locale.fromSubtags( + languageCode: localSplit[0], + scriptCode: localSplit[1], + countryCode: localSplit[2], + ); + } + return null; +} diff --git a/lib/common/app_ports.dart b/lib/common/app_ports.dart new file mode 100644 index 0000000000..215d0c5d79 --- /dev/null +++ b/lib/common/app_ports.dart @@ -0,0 +1,32 @@ +import 'package:fl_clash/common/provider_reader.dart'; +import 'package:fl_clash/models/models.dart'; + +abstract interface class WindowPort { + Future show(); + + Future hide(); + + Future close(); + + Future get isVisible; + + void forceExit(); +} + +abstract interface class TrayPort { + Future shutdown(); + + Future update({ + required TrayState trayState, + required Traffic traffic, + required ProviderReader read, + }); +} + +abstract interface class NavigationPort { + List getItems({bool openLogs, bool hasProxies}); +} + +WindowPort? windowPort; +TrayPort? trayPort; +NavigationPort? navigationPort; diff --git a/lib/common/archive.dart b/lib/common/archive.dart deleted file mode 100644 index 2f05996ce2..0000000000 --- a/lib/common/archive.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:io'; - -import 'package:archive/archive_io.dart'; -import 'package:path/path.dart'; - -extension ArchiveExt on Archive { - void addDirectoryToArchive(String dirPath, String parentPath) { - final dir = Directory(dirPath); - if (!dir.existsSync()) { - return; - } - final entities = dir.listSync(recursive: false); - for (final entity in entities) { - final relativePath = relative(entity.path, from: parentPath); - if (entity is File) { - final data = entity.readAsBytesSync(); - final archiveFile = ArchiveFile(relativePath, data.length, data); - addFile(archiveFile); - } - } - } -} diff --git a/lib/common/cache.dart b/lib/common/cache.dart index d677ac2011..13424bb457 100644 --- a/lib/common/cache.dart +++ b/lib/common/cache.dart @@ -50,6 +50,6 @@ extension CacheManagerExt on CacheManager { ); } } - streamController.close(); + unawaited(streamController.close()); } } diff --git a/lib/common/changelog.dart b/lib/common/changelog.dart new file mode 100644 index 0000000000..9c0a38f1e9 --- /dev/null +++ b/lib/common/changelog.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:fl_clash/enum/enum.dart'; +import 'package:fl_clash/l10n/l10n.dart'; +import 'package:fl_clash/models/changelog.dart'; + +import 'common.dart'; + +const releaseChangelogJsonMarker = ''; + +String changelogGroupTitle( + AppLocalizations appLocalizations, + ChangelogType type, +) => switch (type) { + ChangelogType.breaking => appLocalizations.changelogBreaking, + ChangelogType.feat => appLocalizations.changelogFeatures, + ChangelogType.fix => appLocalizations.changelogFixes, + ChangelogType.perf => appLocalizations.changelogPerformance, + ChangelogType.revert => appLocalizations.changelogReverts, + ChangelogType.unknown => '', +}; + +ChangelogVersion? parseReleaseChangelog(String? body) { + if (body == null) { + return null; + } + final begin = body.indexOf(releaseChangelogJsonMarker); + if (begin < 0) { + return null; + } + final start = begin + releaseChangelogJsonMarker.length; + final end = body.indexOf(_releaseChangelogJsonEndMarker, start); + if (end < 0) { + return null; + } + try { + final changelog = Changelog.fromJson( + jsonDecode(body.substring(start, end)) as Map, + ); + if (!changelog.isSupported) { + commonPrint.log( + 'changelog schema ${changelog.schemaVersion} is not supported', + logLevel: LogLevel.warning, + ); + return null; + } + return changelog.versions.firstOrNull; + } catch (error) { + commonPrint.log( + 'changelog decode failed ${compactError(error)}', + logLevel: LogLevel.warning, + ); + return null; + } +} diff --git a/lib/common/color.dart b/lib/common/color.dart index 4df12565b6..f6362886c1 100644 --- a/lib/common/color.dart +++ b/lib/common/color.dart @@ -101,15 +101,6 @@ extension ColorExtension on Color { factor, )!; } - - Color blendLighten(BuildContext context, {double factor = 0.1}) { - final brightness = Theme.of(context).brightness; - return Color.lerp( - this, - brightness == Brightness.dark ? Colors.black : Colors.white, - factor, - )!; - } } extension ColorSchemeExtension on ColorScheme { @@ -120,3 +111,10 @@ extension ColorSchemeExtension on ColorScheme { ) : this; } + +Color? getDelayColor(int? delay) { + if (delay == null) return null; + if (delay < 0) return Colors.red; + if (delay < 600) return Colors.green; + return const Color(0xFFC57F0A); +} diff --git a/lib/common/common.dart b/lib/common/common.dart index f606be9ad0..037d8e6222 100644 --- a/lib/common/common.dart +++ b/lib/common/common.dart @@ -1,10 +1,14 @@ export 'app_localizations.dart'; +export 'app_ports.dart'; +export 'changelog.dart'; export 'color.dart'; export 'compute.dart'; export 'constant.dart'; export 'context.dart'; export 'converter.dart'; export 'datetime.dart'; +export 'dialog.dart'; +export 'exception.dart'; export 'file.dart'; export 'fixed.dart'; export 'function.dart'; @@ -16,22 +20,21 @@ export 'input_limits.dart'; export 'iterable.dart'; export 'javascript.dart'; export 'keyboard.dart'; -export 'launch.dart'; +export 'layout.dart'; export 'link.dart'; export 'lock.dart'; export 'measure.dart'; export 'mixin.dart'; -export 'navigation.dart'; export 'navigator.dart'; export 'network.dart'; export 'num.dart'; export 'package.dart'; export 'path.dart'; -export 'permission.dart'; export 'picker.dart'; export 'preferences.dart'; export 'print.dart'; export 'protocol.dart'; +export 'provider_reader.dart'; export 'proxy.dart'; export 'render.dart'; export 'request.dart'; @@ -40,8 +43,6 @@ export 'snowflake.dart'; export 'string.dart'; export 'system.dart'; export 'task.dart'; +export 'task_pool.dart'; export 'text.dart'; -export 'tray.dart'; -export 'utils.dart'; -export 'window.dart'; export 'yaml.dart'; diff --git a/lib/common/compute.dart b/lib/common/compute.dart index 1564a72132..0a51d499ee 100644 --- a/lib/common/compute.dart +++ b/lib/common/compute.dart @@ -91,6 +91,10 @@ SelectedProxyState computeRealSelectedProxyState( ); } +String delayTestKey(String testUrl, String proxyName) { + return '$testUrl\u0000$proxyName'; +} + DelayState computeProxyDelayState({ required String proxyName, required String testUrl, diff --git a/lib/common/constant.dart b/lib/common/constant.dart index f7abf870fc..15a1e3cc59 100644 --- a/lib/common/constant.dart +++ b/lib/common/constant.dart @@ -47,13 +47,16 @@ String _randomPipeId() { final defaultTextScaleFactor = WidgetsBinding.instance.platformDispatcher.textScaleFactor; + const httpTimeoutDuration = Duration(milliseconds: 5000); -/// Keep at or below the Core's delay-test concurrency (`mBatch` in -/// core/common.go). Surplus requests queue inside the Core behind a full wave -/// of 5s timeouts, which no RPC timeout can cover. -const maxConcurrentDelayTests = 50; -const moreDuration = Duration(milliseconds: 100); +const delayTestGuardDuration = Duration(seconds: 30); + +const coreConnectionWaitDuration = Duration(seconds: 10); + +/// Keep at or below the Core's delay-test concurrency (`delayTestConcurrency` +/// in core/common.go). +const maxConcurrentDelayTests = 16; const animateDuration = Duration(milliseconds: 100); const midDuration = Duration(milliseconds: 200); const commonDuration = Duration(milliseconds: 300); @@ -71,9 +74,10 @@ const profilesDirectoryName = 'profiles'; const localhost = '127.0.0.1'; const clashConfigKey = 'clash_config'; const configKey = 'config'; +const systemDnsRecordKey = 'system_dns_record'; +const defaultSystemDnsFallback = '223.5.5.5'; const double dialogCommonWidth = 300; const repository = 'chen08209/FlClash'; -const defaultExternalController = '127.0.0.1:9090'; const maxMobileWidth = 600; const maxLaptopWidth = 840; const defaultTestUrl = 'https://www.gstatic.com/generate_204'; @@ -83,37 +87,19 @@ final commonFilter = ImageFilter.blur( tileMode: TileMode.clamp, ); -const listEquality = ListEquality(); -const navigationItemListEquality = ListEquality(); const trackerInfoListEquality = ListEquality(); const stringListEquality = ListEquality(); const intListEquality = ListEquality(); const logListEquality = ListEquality(); -const groupListEquality = ListEquality(); const ruleListEquality = ListEquality(); const scriptListEquality = ListEquality