From d27668107fd7c9fd40f61bbcc18c76590448f868 Mon Sep 17 00:00:00 2001 From: chen08209 Date: Sat, 11 Jul 2026 19:23:48 +0800 Subject: [PATCH 1/6] Adjust android process Optimize core service Fix some issues Optimize more details --- .agents/architecture.md | 261 ++++- .agents/commands.md | 69 +- .agents/project.md | 6 +- .agents/rules.md | 33 + .agents/skills.md | 5 +- .agents/skills/core-platform/SKILL.md | 58 +- .agents/skills/ui-work/SKILL.md | 19 +- .github/scripts/generate_release_notes.sh | 28 + .../scripts/generate_release_notes_test.sh | 66 ++ .github/workflows/build.yaml | 110 +- .gitignore | 3 +- AGENTS.md | 9 + Makefile | 4 +- android/app/build.gradle.kts | 34 +- android/app/proguard-rules.pro | 4 +- android/app/src/debug/AndroidManifest.xml | 4 - android/app/src/main/AndroidManifest.xml | 21 +- .../com/follow/clash/BroadcastReceiver.kt | 29 - .../src/main/kotlin/com/follow/clash/Ext.kt | 75 +- .../{Application.kt => FlClashApplication.kt} | 3 +- .../kotlin/com/follow/clash/MainActivity.kt | 23 +- .../com/follow/clash/QuickActionActivity.kt | 28 + .../main/kotlin/com/follow/clash/Service.kt | 187 ---- .../follow/clash/ServiceBroadcastReceiver.kt | 58 + .../com/follow/clash/ServiceController.kt | 227 ++++ .../kotlin/com/follow/clash/ServiceState.kt | 299 ++++++ .../src/main/kotlin/com/follow/clash/State.kt | 205 ---- .../kotlin/com/follow/clash/TempActivity.kt | 37 - .../kotlin/com/follow/clash/TileService.kt | 60 +- .../{Package.kt => InstalledPackage.kt} | 2 +- .../follow/clash/packages/PackageResolver.kt | 174 +++ .../com/follow/clash/plugins/AppPlugin.kt | 340 ++---- .../com/follow/clash/plugins/ServicePlugin.kt | 171 ++- .../com/follow/clash/plugins/TilePlugin.kt | 12 +- android/app/src/main/res/xml/file_paths.xml | 6 - .../main/res/xml/network_security_config.xml | 14 - android/app/src/profile/AndroidManifest.xml | 7 - android/build.gradle.kts | 3 - android/common/build.gradle.kts | 13 +- android/common/consumer-rules.pro | 0 .../com/follow/clash/common/Components.kt | 12 +- .../java/com/follow/clash/common/Enums.kt | 7 +- .../main/java/com/follow/clash/common/Ext.kt | 184 +--- .../com/follow/clash/common/GlobalState.kt | 42 +- .../java/com/follow/clash/common/Service.kt | 78 -- .../java/com/follow/clash/common/Utils.kt | 14 - .../common/src/main/res/values/strings.xml | 3 +- android/core/build.gradle.kts | 16 - android/core/src/main/cpp/core.cpp | 36 +- android/core/src/main/cpp/jni_helper.cpp | 34 +- android/core/src/main/cpp/jni_helper.h | 4 +- .../main/java/com/follow/clash/core/Core.kt | 50 +- .../com/follow/clash/core/TunInterface.kt | 3 +- android/gradle.properties | 1 - android/gradle/libs.versions.toml | 6 - android/service/build.gradle.kts | 15 +- android/service/src/main/AndroidManifest.xml | 19 +- .../follow/clash/service/IAckInterface.aidl | 8 - .../clash/service/ICallbackInterface.aidl | 8 - .../follow/clash/service/IEventInterface.aidl | 8 - .../clash/service/IRemoteInterface.aidl | 20 - .../clash/service/IResultInterface.aidl | 6 - .../follow/clash/service/IVoidInterface.aidl | 6 - .../clash/service/models/AccessControl.aidl | 4 - .../service/models/NotificationParams.aidl | 4 - .../clash/service/models/VpnOptions.aidl | 6 - .../com/follow/clash/service/CommonService.kt | 64 -- .../com/follow/clash/service/FilesProvider.kt | 127 +-- .../com/follow/clash/service/IBaseService.kt | 21 - .../follow/clash/service/ManagedService.kt | 22 + .../com/follow/clash/service/ProxyService.kt | 52 + .../com/follow/clash/service/RemoteService.kt | 196 ---- .../com/follow/clash/service/ServiceConfig.kt | 25 + .../java/com/follow/clash/service/State.kt | 22 - .../com/follow/clash/service/VpnService.kt | 308 +++--- .../service/models/NotificationParams.kt | 31 +- .../follow/clash/service/models/Traffic.kt | 34 +- .../follow/clash/service/models/VpnOptions.kt | 144 +-- .../follow/clash/service/modules/Module.kt | 19 - .../clash/service/modules/ModuleLoader.kt | 51 - .../service/modules/NetworkObserveModule.kt | 113 +- .../service/modules/NotificationModule.kt | 87 +- .../clash/service/modules/ServiceModules.kt | 59 ++ .../clash/service/modules/SuspendModule.kt | 50 +- android/settings.gradle.kts | 5 +- arb/intl_en.arb | 4 +- arb/intl_ja.arb | 4 +- arb/intl_ru.arb | 4 +- arb/intl_zh_CN.arb | 4 +- core/Clash.Meta | 2 +- core/action.go | 193 ---- core/common.go | 20 +- core/constant.go | 77 +- core/go.mod | 31 +- core/go.sum | 62 +- core/hub.go | 227 ++-- core/lib.go | 90 +- core/message.go | 124 +++ core/method.go | 276 +++++ core/server.go | 75 +- lib/application.dart | 5 +- lib/common/color.dart | 20 +- lib/common/common.dart | 1 - lib/common/constant.dart | 12 +- lib/common/dav_client.dart | 46 + lib/common/function.dart | 16 + lib/common/future.dart | 18 +- lib/common/migration.dart | 161 ++- lib/common/path.dart | 9 +- lib/common/permission.dart | 32 +- lib/common/request.dart | 58 +- lib/common/system.dart | 130 +-- lib/common/tray.dart | 2 +- lib/common/utils.dart | 7 +- lib/core/controller.dart | 98 +- lib/core/core.dart | 2 + lib/core/desktop/helper_client.dart | 385 +++++++ lib/core/desktop/launcher.dart | 95 ++ lib/core/desktop/lifecycle.dart | 798 ++++++++++++++ lib/core/desktop/model.dart | 177 ++++ lib/core/desktop/rpc_client.dart | 208 ++++ lib/core/desktop/transport.dart | 397 +++++++ lib/core/event.dart | 74 +- lib/core/interface.dart | 279 ++--- lib/core/lib.dart | 135 ++- lib/core/method.dart | 137 +++ lib/core/service.dart | 218 ++-- lib/core/transport.dart | 106 -- lib/database/generated/database.g.dart | 22 +- lib/database/rules.dart | 4 +- lib/enum/enum.dart | 57 +- lib/l10n/intl/messages_en.dart | 6 +- lib/l10n/intl/messages_ja.dart | 6 +- lib/l10n/intl/messages_ru.dart | 6 +- lib/l10n/intl/messages_zh_CN.dart | 6 +- lib/l10n/l10n.dart | 24 +- lib/manager/android_manager.dart | 8 - lib/manager/app_manager.dart | 5 +- lib/manager/core_manager.dart | 13 +- lib/manager/hotkey_manager.dart | 2 +- lib/manager/tile_manager.dart | 8 +- lib/manager/vpn_manager.dart | 4 +- lib/models/app.dart | 5 +- lib/models/clash_config.dart | 2 +- lib/models/common.dart | 121 ++- lib/models/core.dart | 34 - lib/models/generated/app.freezed.dart | 50 +- lib/models/generated/common.freezed.dart | 56 +- lib/models/generated/common.g.dart | 6 +- lib/models/generated/core.freezed.dart | 541 ---------- lib/models/generated/core.g.dart | 74 -- lib/plugins/app.dart | 11 + lib/plugins/service.dart | 37 +- lib/providers/action.dart | 990 +----------------- lib/providers/actions/backup.dart | 62 ++ lib/providers/actions/common.dart | 84 ++ lib/providers/actions/core.dart | 83 ++ lib/providers/actions/geo_resource.dart | 20 + lib/providers/actions/profiles.dart | 151 +++ lib/providers/actions/proxies.dart | 108 ++ lib/providers/actions/setup.dart | 432 ++++++++ lib/providers/actions/store.dart | 58 + lib/providers/actions/system.dart | 117 +++ lib/providers/actions/system_exit.dart | 70 ++ lib/providers/actions/theme.dart | 20 + lib/providers/app.dart | 13 +- lib/providers/database.dart | 12 +- lib/providers/generated/action.g.dart | 52 +- lib/providers/generated/app.g.dart | 159 +-- lib/providers/generated/config.g.dart | 48 +- lib/providers/generated/database.g.dart | 30 +- lib/providers/generated/state.g.dart | 14 +- lib/providers/state.dart | 9 +- lib/state.dart | 76 +- lib/views/access.dart | 5 +- lib/views/application_setting.dart | 176 ++-- lib/views/backup_and_restore.dart | 87 +- lib/views/config/advanced.dart | 73 +- lib/views/config/dns.dart | 525 +++++----- lib/views/config/general.dart | 390 +++---- lib/views/config/network.dart | 236 ++--- lib/views/config/on_demand.dart | 11 +- lib/views/dashboard/dashboard.dart | 136 +-- .../dashboard/widgets/core_status_button.dart | 159 +++ lib/views/dashboard/widgets/memory_info.dart | 96 +- .../dashboard/widgets/outbound_mode.dart | 10 +- lib/views/dashboard/widgets/start_button.dart | 271 +++-- lib/views/developer.dart | 20 +- lib/views/profiles/edit.dart | 16 +- .../profiles/overwrite/custom/proxies.dart | 12 +- .../overwrite/custom/proxy_providers.dart | 12 +- lib/views/profiles/profiles.dart | 6 +- lib/views/proxies/card.dart | 2 +- lib/views/proxies/common.dart | 38 +- lib/views/proxies/providers.dart | 3 +- lib/views/proxies/tab.dart | 66 +- lib/views/resources.dart | 100 +- lib/views/theme.dart | 34 +- lib/views/tools.dart | 46 +- lib/widgets/input.dart | 10 +- lib/widgets/list.dart | 407 +++---- lib/widgets/loading.dart | 379 ++++++- lib/widgets/open_container.dart | 108 +- lib/widgets/super_grid.dart | 285 ++--- lib/widgets/theme.dart | 23 +- linux/flutter/generated_plugin_registrant.cc | 4 - linux/flutter/generated_plugins.cmake | 1 - linux/packaging/deb/make_config.yaml | 3 +- linux/packaging/rpm/make_config.yaml | 5 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 - macos/Podfile.lock | 2 +- plugins/proxy/CHANGELOG.md | 9 +- plugins/proxy/README.md | 19 +- plugins/proxy/lib/proxy.dart | 619 +---------- plugins/proxy/lib/proxy_method_channel.dart | 20 +- .../proxy/lib/proxy_platform_interface.dart | 4 +- plugins/proxy/lib/src/linux_proxy.dart | 383 +++++++ plugins/proxy/lib/src/macos_proxy.dart | 130 +++ plugins/proxy/lib/src/proxy_command.dart | 58 + plugins/proxy/pubspec.yaml | 59 +- plugins/proxy/test/proxy_test.dart | 267 ++++- plugins/proxy/windows/proxy_plugin.cpp | 54 +- plugins/proxy/windows/proxy_plugin.h | 4 +- .../proxy/windows/test/proxy_plugin_test.cpp | 48 + .../android/src/main/AndroidManifest.xml | 4 +- plugins/rust_api/cargokit/build_pod.sh | 2 - plugins/rust_api/lib/src/rust/api/ipc.dart | 11 +- .../rust_api/lib/src/rust/frb_generated.dart | 96 +- .../lib/src/rust/frb_generated.io.dart | 15 +- .../lib/src/rust/frb_generated.web.dart | 15 +- plugins/rust_api/pubspec.yaml | 2 +- plugins/rust_api/rust/Cargo.lock | 2 +- plugins/rust_api/rust/Cargo.toml | 4 +- plugins/rust_api/rust/src/api/ipc.rs | 653 +++++++----- plugins/rust_api/rust/src/api/mod.rs | 2 +- plugins/rust_api/rust/src/frb_generated.rs | 102 +- plugins/setup/analysis_options.yaml | 5 + .../android/src/main/AndroidManifest.xml | 4 +- plugins/setup/buildkit/build_pod.sh | 29 +- .../build_tool/lib/src/build_cache.dart | 254 +++++ .../build_tool/lib/src/build_tool.dart | 119 ++- .../build_tool/lib/src/fingerprint.dart | 97 ++ .../build_tool/lib/src/go_builder.dart | 236 ++++- .../buildkit/build_tool/lib/src/options.dart | 12 + .../build_tool/lib/src/rust_builder.dart | 133 ++- .../build_tool/test/build_cache_test.dart | 110 ++ .../build_tool/test/fingerprint_test.dart | 71 ++ .../build_tool/test/windows_cache_test.dart | 132 +++ plugins/setup/buildkit/cmake/buildkit.cmake | 9 +- plugins/setup/buildkit/gradle/plugin.gradle | 36 +- plugins/setup/buildkit/run_build_tool.cmd | 4 +- plugins/setup/buildkit/run_build_tool.sh | 2 - plugins/setup/lib/setup.dart | 11 +- plugins/setup/macos/setup.podspec | 2 +- plugins/wifi_ssid/android/build.gradle | 4 +- .../android/src/main/AndroidManifest.xml | 4 +- .../com/example/wifi_ssid/WifiSsidPlugin.kt | 162 --- .../follow/clash/wifi_ssid/WifiSsidPlugin.kt | 355 +++++++ plugins/wifi_ssid/lib/wifi_ssid_manager.dart | 42 +- plugins/wifi_ssid/linux/wifi_ssid_plugin.cc | 71 +- .../Sources/wifi_ssid/WifiSsidPlugin.swift | 54 +- plugins/wifi_ssid/pubspec.yaml | 10 +- .../test/wifi_ssid_manager_test.dart | 76 ++ plugins/wifi_ssid/windows/CMakeLists.txt | 2 +- .../wifi_ssid/windows/wifi_ssid_plugin.cpp | 147 +-- plugins/wifi_ssid/windows/wifi_ssid_plugin.h | 3 +- pubspec.lock | 278 +++-- pubspec.yaml | 40 +- services/helper/Cargo.lock | 2 + services/helper/Cargo.toml | 3 + services/helper/build.rs | 9 +- services/helper/src/main.rs | 6 +- services/helper/src/service/hub.rs | 637 ++++++++++- services/helper/src/service/mod.rs | 5 - services/helper/src/service/windows.rs | 321 +++++- setup.dart | 40 +- test/common/color_test.dart | 16 + test/common/dav_client_test.dart | 89 ++ test/common/function_test.dart | 53 + test/common/migration_test.dart | 257 +++++ test/common/task_test.dart | 207 ++++ test/common/utils_test.dart | 8 +- test/core/controller_test.dart | 169 +-- test/core/desktop/fakes.dart | 243 +++++ test/core/desktop/helper_client_test.dart | 346 ++++++ test/core/desktop/launcher_test.dart | 108 ++ test/core/desktop/lifecycle_test.dart | 498 +++++++++ test/core/desktop/rpc_client_test.dart | 206 ++++ test/core/desktop/transport_test.dart | 279 +++++ test/core/protocol_contract_test.dart | 287 +++++ test/core/service_test.dart | 112 ++ test/core/transport_test.dart | 13 + test/database/dao_coverage_test.dart | 480 +++++++++ test/database/generated_contract_test.dart | 379 +++++++ test/database/rules_test.dart | 40 + test/fixtures/core_protocol.json | 50 + test/l10n/app_localizations_test.dart | 589 +++++++++++ test/l10n/localization_contract_test.dart | 67 ++ test/manager/core_manager_test.dart | 52 + test/models/common_test.dart | 116 +- test/models/config_test.dart | 66 +- test/models/core_test.dart | 24 - test/plugins/app_test.dart | 46 + test/providers/action_test.dart | 584 +++++++++++ test/providers/app_test.dart | 24 +- test/providers/config_test.dart | 3 +- test/providers/permission_test.dart | 93 ++ test/providers/state_derived_test.dart | 388 +++++++ test/providers/system_action_test.dart | 75 ++ test/setup_test.dart | 14 + test/state_test.dart | 10 + test/widgets/core_status_button_test.dart | 187 ++++ test/widgets/developer_test.dart | 31 + test/widgets/input_test.dart | 374 ++++++- test/widgets/loading_test.dart | 105 ++ test/widgets/memory_info_test.dart | 115 ++ test/widgets/open_container_test.dart | 108 ++ test/widgets/palette_test.dart | 98 ++ test/widgets/proxies_tab_test.dart | 124 +++ test/widgets/side_sheet_test.dart | 155 +++ test/widgets/start_button_test.dart | 247 +++++ test/widgets/super_grid_test.dart | 152 +++ test/widgets/tab_test.dart | 173 +++ test/widgets/views_smoke_test.dart | 532 ++++++++++ .../flutter/generated_plugin_registrant.cc | 3 - windows/flutter/generated_plugins.cmake | 1 - windows/packaging/exe/inno_setup.iss | 24 +- 327 files changed, 22280 insertions(+), 9284 deletions(-) create mode 100644 .github/scripts/generate_release_notes.sh create mode 100644 .github/scripts/generate_release_notes_test.sh delete mode 100644 android/app/src/main/kotlin/com/follow/clash/BroadcastReceiver.kt rename android/app/src/main/kotlin/com/follow/clash/{Application.kt => FlClashApplication.kt} (85%) create mode 100644 android/app/src/main/kotlin/com/follow/clash/QuickActionActivity.kt delete mode 100644 android/app/src/main/kotlin/com/follow/clash/Service.kt create mode 100644 android/app/src/main/kotlin/com/follow/clash/ServiceBroadcastReceiver.kt create mode 100644 android/app/src/main/kotlin/com/follow/clash/ServiceController.kt create mode 100644 android/app/src/main/kotlin/com/follow/clash/ServiceState.kt delete mode 100644 android/app/src/main/kotlin/com/follow/clash/State.kt delete mode 100644 android/app/src/main/kotlin/com/follow/clash/TempActivity.kt rename android/app/src/main/kotlin/com/follow/clash/models/{Package.kt => InstalledPackage.kt} (85%) create mode 100644 android/app/src/main/kotlin/com/follow/clash/packages/PackageResolver.kt delete mode 100644 android/app/src/main/res/xml/file_paths.xml delete mode 100644 android/app/src/main/res/xml/network_security_config.xml delete mode 100644 android/app/src/profile/AndroidManifest.xml delete mode 100644 android/common/consumer-rules.pro delete mode 100644 android/common/src/main/java/com/follow/clash/common/Service.kt delete mode 100644 android/common/src/main/java/com/follow/clash/common/Utils.kt delete mode 100644 android/service/src/main/aidl/com/follow/clash/service/IAckInterface.aidl delete mode 100644 android/service/src/main/aidl/com/follow/clash/service/ICallbackInterface.aidl delete mode 100644 android/service/src/main/aidl/com/follow/clash/service/IEventInterface.aidl delete mode 100644 android/service/src/main/aidl/com/follow/clash/service/IRemoteInterface.aidl delete mode 100644 android/service/src/main/aidl/com/follow/clash/service/IResultInterface.aidl delete mode 100644 android/service/src/main/aidl/com/follow/clash/service/IVoidInterface.aidl delete mode 100644 android/service/src/main/aidl/com/follow/clash/service/models/AccessControl.aidl delete mode 100644 android/service/src/main/aidl/com/follow/clash/service/models/NotificationParams.aidl delete mode 100644 android/service/src/main/aidl/com/follow/clash/service/models/VpnOptions.aidl delete mode 100644 android/service/src/main/java/com/follow/clash/service/CommonService.kt delete mode 100644 android/service/src/main/java/com/follow/clash/service/IBaseService.kt create mode 100644 android/service/src/main/java/com/follow/clash/service/ManagedService.kt create mode 100644 android/service/src/main/java/com/follow/clash/service/ProxyService.kt delete mode 100644 android/service/src/main/java/com/follow/clash/service/RemoteService.kt create mode 100644 android/service/src/main/java/com/follow/clash/service/ServiceConfig.kt delete mode 100644 android/service/src/main/java/com/follow/clash/service/State.kt delete mode 100644 android/service/src/main/java/com/follow/clash/service/modules/Module.kt delete mode 100644 android/service/src/main/java/com/follow/clash/service/modules/ModuleLoader.kt create mode 100644 android/service/src/main/java/com/follow/clash/service/modules/ServiceModules.kt delete mode 100644 core/action.go create mode 100644 core/message.go create mode 100644 core/method.go create mode 100644 lib/core/desktop/helper_client.dart create mode 100644 lib/core/desktop/launcher.dart create mode 100644 lib/core/desktop/lifecycle.dart create mode 100644 lib/core/desktop/model.dart create mode 100644 lib/core/desktop/rpc_client.dart create mode 100644 lib/core/desktop/transport.dart create mode 100644 lib/core/method.dart delete mode 100644 lib/core/transport.dart create mode 100644 lib/providers/actions/backup.dart create mode 100644 lib/providers/actions/common.dart create mode 100644 lib/providers/actions/core.dart create mode 100644 lib/providers/actions/geo_resource.dart create mode 100644 lib/providers/actions/profiles.dart create mode 100644 lib/providers/actions/proxies.dart create mode 100644 lib/providers/actions/setup.dart create mode 100644 lib/providers/actions/store.dart create mode 100644 lib/providers/actions/system.dart create mode 100644 lib/providers/actions/system_exit.dart create mode 100644 lib/providers/actions/theme.dart create mode 100644 lib/views/dashboard/widgets/core_status_button.dart create mode 100644 plugins/proxy/lib/src/linux_proxy.dart create mode 100644 plugins/proxy/lib/src/macos_proxy.dart create mode 100644 plugins/proxy/lib/src/proxy_command.dart create mode 100644 plugins/setup/analysis_options.yaml create mode 100644 plugins/setup/buildkit/build_tool/lib/src/build_cache.dart create mode 100644 plugins/setup/buildkit/build_tool/lib/src/fingerprint.dart create mode 100644 plugins/setup/buildkit/build_tool/test/build_cache_test.dart create mode 100644 plugins/setup/buildkit/build_tool/test/fingerprint_test.dart create mode 100644 plugins/setup/buildkit/build_tool/test/windows_cache_test.dart delete mode 100644 plugins/wifi_ssid/android/src/main/kotlin/com/example/wifi_ssid/WifiSsidPlugin.kt create mode 100644 plugins/wifi_ssid/android/src/main/kotlin/com/follow/clash/wifi_ssid/WifiSsidPlugin.kt create mode 100644 plugins/wifi_ssid/test/wifi_ssid_manager_test.dart create mode 100644 test/common/color_test.dart create mode 100644 test/common/dav_client_test.dart create mode 100644 test/common/migration_test.dart create mode 100644 test/common/task_test.dart create mode 100644 test/core/desktop/fakes.dart create mode 100644 test/core/desktop/helper_client_test.dart create mode 100644 test/core/desktop/launcher_test.dart create mode 100644 test/core/desktop/lifecycle_test.dart create mode 100644 test/core/desktop/rpc_client_test.dart create mode 100644 test/core/desktop/transport_test.dart create mode 100644 test/core/protocol_contract_test.dart create mode 100644 test/core/service_test.dart create mode 100644 test/core/transport_test.dart create mode 100644 test/database/dao_coverage_test.dart create mode 100644 test/database/generated_contract_test.dart create mode 100644 test/database/rules_test.dart create mode 100644 test/fixtures/core_protocol.json create mode 100644 test/l10n/app_localizations_test.dart create mode 100644 test/l10n/localization_contract_test.dart create mode 100644 test/manager/core_manager_test.dart create mode 100644 test/plugins/app_test.dart create mode 100644 test/providers/permission_test.dart create mode 100644 test/providers/state_derived_test.dart create mode 100644 test/providers/system_action_test.dart create mode 100644 test/state_test.dart create mode 100644 test/widgets/core_status_button_test.dart create mode 100644 test/widgets/developer_test.dart create mode 100644 test/widgets/memory_info_test.dart create mode 100644 test/widgets/open_container_test.dart create mode 100644 test/widgets/palette_test.dart create mode 100644 test/widgets/proxies_tab_test.dart create mode 100644 test/widgets/side_sheet_test.dart create mode 100644 test/widgets/start_button_test.dart create mode 100644 test/widgets/super_grid_test.dart create mode 100644 test/widgets/tab_test.dart create mode 100644 test/widgets/views_smoke_test.dart diff --git a/.agents/architecture.md b/.agents/architecture.md index dc54168311..2f0e4439f4 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -7,23 +7,155 @@ The Go proxy core in `core/` operates in two modes. Android lib mode: - Go core is compiled as a C shared library, `libclash.so`, through `go build -buildmode=c-shared` with CGO. -- Flutter calls it via FFI through the `service` plugin. -- Dart-side implementation: `lib/core/lib.dart` (`CoreLib`). +- The Android `:core` module owns JNI access to the in-process library. Flutter crosses the `${packageName}/service` + MethodChannel through `lib/plugins/service.dart` and Android's `ServicePlugin` rather than talking to JNI directly. +- `lib/core/lib.dart` (`CoreLib`) implements the shared Core interface, gates method calls on its connection completer, + initializes and synchronizes Android shared state, and closes the native service path exactly once. +- Because Core is in the application process on Android, application RSS already includes Core memory. Desktop core mode: - Go core runs as a separate process with `CGO_ENABLED=0`. -- Flutter communicates via JSON over socket, using a Unix socket on macOS/Linux and TCP on Windows. -- Dart-side implementation: `lib/core/service.dart` (`CoreService`). +- `rust_api` provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe on Windows. + Dart now owns the transport state, RPC correlation, process ownership, and lifecycle convergence above those primitives. +- `lib/core/service.dart` (`CoreService`) is the composition root. It wires the IPC transport, launcher selection, + lifecycle controller, RPC client, and crash-event bridge; it is no longer the whole desktop implementation by itself. +- `lib/core/desktop/transport.dart` converts native IPC frames into ready, connected, disconnected, failed, and data + events. A replaceable binding keeps RPC subscriptions stable when a failed or stale transport must be rebuilt. +- `lib/core/desktop/rpc_client.dart` owns request IDs and pending completers, waits up to 10 seconds for a connection, + applies a three-minute default method timeout, unwraps `CoreMethodResponse`, and fails all pending calls when transport + disconnects or closes. +- `lib/core/desktop/lifecycle.dart` serializes process intents and owns the authoritative desktop state machine. +- `lib/core/desktop/launcher.dart` abstracts direct child-process and Windows Helper ownership through idempotent process + leases. `lib/core/desktop/helper_client.dart` is the typed loopback HTTP client for the privileged Helper. `lib/core/controller.dart` (`CoreController`) selects the implementation based on platform. `lib/core/interface.dart` defines the shared `CoreHandlerInterface`. Key Go core files: - `core/hub.go`: handler functions. -- `core/action.go`: dispatch. +- `core/method.go`: MethodChannel-style method-call dispatch and response envelopes. +- `core/message.go`: non-blocking priority/bulk event queues and bounded message batching. - `core/lib.go`: CGO exports. -- `core/server.go`: socket server. +- `core/server.go`: desktop socket/named-pipe client and framed message forwarding. + +## Lifecycle Ownership And Convergence + +### Shared Flutter Layer + +`CoreController.start()`, `restart()`, `stop()`, and `close()` are the only shared lifecycle facade. `close()` is terminal; +callers must not try to reuse a closed platform implementation. + +`CoreAction` in `lib/providers/actions/core.dart` owns the user-facing Core status and setup sequence: + +- `startCore()` publishes `connecting`, starts the platform Core, publishes `connected`, then initializes Core state. A + startup error publishes `disconnected` and displays the error. +- `restartCore()` coalesces overlapping callers behind one worker. `_requestedRestartRevision` records newer requests, + while `_latestExplicitStart` retains the newest requested post-restart running intent. After the lifecycle restart and + `initCore()`, the worker reapplies profile/running state until it has consumed the latest revision. +- The provider is an orchestration and presentation layer, not a process owner. Platform lifecycle code remains responsible + for determining whether a Core process/service is actually running. + +Application exit is centralized in `SystemAction` and `SystemExitCoordinator`: + +1. Optionally save config and clean up DNS, system proxy, and tray resources in parallel. +2. Close the desktop window. +3. Call terminal `CoreController.close()`. +4. Exit the application exactly once. + +The coordinator is idempotent, continues later cleanup steps after an earlier error, preserves the first error for the +caller, and uses a three-second watchdog as an emergency application-exit path. `Application.dispose()` and +`CoreManager.onCrash()` do not independently destroy Core; this avoids competing shutdown owners. + +### Desktop Lifecycle + +`DesktopCoreLifecycle` is a latest-desired-intent reconciler, not a queue that blindly executes every request: + +- Public intents receive monotonically increasing revisions and target running, restarted, stopped, or closed. +- Observable phases are `idle`, `starting`, `running`, `stopping`, `failed`, and `closed`. +- A completed command reports `applied`, `coalesced`, or `superseded`, allowing callers and tests to distinguish a command + that won from one satisfied or replaced by a newer compatible intent. +- Startup opens or replaces the IPC transport, resolves a launcher, generates a 128-bit lowercase hexadecimal session ID, + launches Core, and waits for the matching connection. Windows additionally verifies that the named-pipe peer PID equals + the process PID returned by the Helper lease. +- Each running session retains its process owner, lease, PID, session ID, and transport connection generation. Stop waits + for both process-exit confirmation and the matching disconnect generation; a missing disconnect replaces the transport + before later starts. +- An unconfirmed process exit is retained as an unconfirmed lease. New start/restart intents fail until ownership can be + cleaned up, preventing two Core instances from being treated as the active session. Terminal close may continue on a + best-effort basis because the application is exiting. +- An unexpected disconnect or transport failure while running is converted to `DesktopCoreFailure`, the owned process is + cleaned up, and `CoreService` emits a Core crash event for the normal UI recovery path. + +Direct launch is used on macOS/Linux and as the Windows fallback when the privileged Helper is not ready. When the Helper +is ready on Windows, the Helper owns the Core child and Dart owns it through a session-scoped lease. + +### Android Service Lifecycle + +Android deliberately keeps Flutter requests optimistic and the native layer authoritative: + +- `ServicePlugin.start()` and `stop()` acknowledge immediately after submitting intent. They do not wait for service + creation, VPN permission, binding, TUN establishment, or teardown. +- `ServiceState` owns the latest `RunRequest`, shared configuration, run time, and `STOPPED`/`STARTING`/`STARTED`/`STOPPING` + state. Identity checks discard obsolete work. `startPreparationLock` serializes permission/setup preparation and + `transitionLock` serializes actual service transitions. +- `ServiceController` owns exactly one `ManagedServiceBinding`, selects `VpnService` or `ProxyService` from `VpnOptions`, + binds with a five-second connection timeout, invokes `ManagedService.start()`/`stop()` off the main thread, and clears + binding/run-time state on failure or disconnection. +- Generic service creation/destruction is lifecycle evidence, not user intent. New commands must flow through + `ServiceState.requestStart()`/`requestStop()` or the explicit system-action handlers instead of inferring intent from a + callback. + +Quick Settings, notification, revoke, and Always-on VPN paths converge on the same owner: + +- With a Flutter engine attached, `ServiceState.handleStartAction()`/`handleStopAction()` forward through `TilePlugin` to + `TileManager`, which updates normal Flutter setup state. Without Flutter, native code restores `SharedState` from + preferences, runs `quickSetup`, checks VPN permission, and submits the native request directly. +- Android may create an Always-on `VpnService` through `onStartCommand()` without FlClash's bound-service path. The service + sends the explicit, permission-protected `VPN_START_REQUESTED` broadcast to `ServiceBroadcastReceiver`, which routes it to + `ServiceState.handleStartAction()` so Core/configuration and the normal binding are restored before TUN is treated as + ready. +- `VpnService.onRevoke()` stops TUN/modules first, then sends `VPN_REVOKED`; the receiver only requests a stop when + `ServiceController` still owns an active VPN binding. +- `ServiceBroadcastReceiver` uses `goAsync()` and an atomic one-shot completion. Normal completion or a nine-second + watchdog calls `PendingResult.finish()` exactly once; the watchdog releases Android's broadcast lease and does not + cancel or redefine the underlying lifecycle intent. + +## Core Protocol And Event Delivery + +The shared protocol uses `CoreMethodCall(id, method, arguments)` and `CoreMethodResponse(id, result, error)` in both +directions. The envelope is the only JSON serialization layer: keep arguments, results, and event data as structured JSON +values rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents, +remain strings. + +Go event delivery is intentionally non-blocking: + +- State-bearing events such as delay, loaded-provider, and geo-update use a 256-entry priority queue. Desktop process + crashes are generated locally by `CoreService` from lifecycle failures rather than sent through the Go queue. +- High-volume log and request/connection events use a separate 256-entry bulk queue, so bulk floods cannot evict state. +- A full queue evicts only its own oldest event and retries the newest event; Core work never blocks on event delivery. +- The batcher flushes at 32 messages or every 16 milliseconds. Priority events are preferred, but one bulk opportunity is + guaranteed after eight priority messages to prevent starvation. + +Desktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate +listener exceptions so one faulty observer does not prevent the remaining events/listeners from running. + +## User-Facing Core And Delay Feedback + +`CoreStatusButton` in `lib/views/dashboard/widgets/core_status_button.dart` is the desktop dashboard's status/restart +surface. It is shown only outside dashboard edit mode and only when `coreLib == null`: + +- Provider state remains authoritative. The widget keeps a separate display-only status so a fast + `connecting -> connected` transition still shows at least 600 milliseconds of progress instead of flashing. +- The hold arms only after an observed transition to `connecting`; mounting while already connecting does not invent a new + delay. A real `disconnected` transition cancels the hold immediately so failure is never hidden, while a long-running + connecting state remains visible after the timer expires. +- Taps during the display hold or while the provider is genuinely connecting are inert. Connected/disconnected taps show + the appropriate confirmation and delegate restart to `CoreAction`; the widget never starts Core directly. + +Proxy delay testing follows the same failure-safe UI rule. `proxyDelayTest()` records an in-progress zero delay, writes the +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. ## State Management @@ -71,17 +203,21 @@ Each manager in `lib/manager/` handles a specific platform concern. Desktop-only `lib/core/controller.dart` (`CoreController`) is a singleton facade over `CoreHandlerInterface`. Public methods delegate to the platform-specific interface, either Android FFI or desktop socket. It has an `@visibleForTesting` constructor and `resetInstance()` for test injection. -Business logic lives in Riverpod notifier classes in `lib/providers/action.dart`: +`lib/providers/action.dart` is the public library entry point for action +providers. The Riverpod notifier implementations are split by responsibility +under `lib/providers/actions/` and joined to the entry point with `part` +directives, so consumers continue to import the same public API: - `CommonAction`: update check and common UI operations. - `SetupAction`: config setup and TUN management. - `BackupAction`: backup/restore with WebDAV sync. -- `CoreAction`: core lifecycle, init, connect, restart, shutdown. -- `SystemAction`: system integration, tray, exit, brightness. +- `CoreAction`: core lifecycle, initialization, coalesced restart, and post-restart profile/running-state application. +- `SystemAction`: system integration, tray, coordinated resource cleanup, terminal Core close, exit, and brightness. - `StoreAction`: profile storage operations. - `ThemeAction`: theme state updates. - `ProxiesAction`: group management and proxy selection. - `ProfilesAction`: profile CRUD, auto-update, import. +- `GeoResourceAction`: geo resource updates and URL configuration. ## Platform Managers @@ -110,10 +246,10 @@ Shared: `setup.dart` is the release build orchestrator: -1. On Windows, pre-builds Go core via `dart run build_tool windows` and reads `core_sha256.json`. -2. Writes `env.json` (`APP_ENV`). -3. Passes SHA256 as `--dart-define=CORE_SHA256=$val`, embedded at compile time for Windows. -4. Activates `flutter_distributor` for packaging. +1. Writes `env.json` (`APP_ENV`). +2. Activates `flutter_distributor` for packaging. +3. Relies on the platform build hook to build the required Core artifacts before + the native application is linked. Go core building is handled by `build_tool`, a standalone Dart CLI in `plugins/setup/buildkit/build_tool/`. @@ -121,25 +257,73 @@ Platform build hooks inside `flutter build` trigger `build_tool` automatically: - macOS: podspec script phase, `build_pod.sh`, `build_tool macos`. - Linux: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool linux`. -- Windows: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool windows`. Debug passes `--dev` via `CMAKE_BUILD_TYPE`. +- Windows: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool windows`. CMake forwards the active configuration through `BUILDKIT_CONFIGURATION`. - Android: Gradle include, `buildkit/gradle/plugin.gradle`, `build_tool android`. -Windows helper auth: - -- Release: Core SHA256 is embedded in both the Flutter app and the Rust helper. The app pings the helper and verifies the token matches. -- Debug: The Rust helper skips token verification when built in debug mode, so `flutter run` works without the SHA256 flow. - -`plugins/setup/` is an FFI plugin that exists only as a build harness. It carries no Dart API, only platform build hooks that trigger Go compilation. Windows builds also compile a Rust helper in `services/helper/` through `RustBuilder`. - -Build configuration defaults live in `build_tool/lib/src/options.dart` and can be overridden via `build_config.yaml`. +### Setup Build Harness Plugin + +`plugins/setup/` is a build-time Flutter plugin, not a runtime Dart or FFI API. Its plugin shape exists so Flutter's native +build graphs can run the Go/Rust build harness before platform consumers need the generated artifacts. Application code +must not import or call it. + +Responsibilities are deliberately split: + +- CocoaPods, Gradle, and CMake hooks schedule a lightweight check on every native build. They do not decide which Go or + Rust files are stale. +- `buildkit/build_tool/` owns target resolution, input fingerprinting, compilation, output copying, and cache validation. +- `core/` and `services/helper/` remain source owners; `libclash/` and Android `jniLibs`/header directories are generated + output locations. +- `setup.dart` remains the release/package orchestrator and does not pre-build + platform artifacts or pass Core integrity data into Dart. + +Platform outputs remain explicit: + +- Android builds the Go core as `c-shared`, then copies `libclash.so` and generated headers into the `:core` Android module. +- macOS and Linux build a standalone `FlClashCore` process used by the desktop socket integration. +- Windows builds `FlClashCore.exe` plus the Rust `FlClashHelperService.exe` privileged helper. + +The hooks follow rust_api/Cargokit's phony-output scheduling pattern, but setup uses its own cache because it builds both a +Go core and, on Windows, a separate Rust helper. Per-target records live under `.dart_tool/setup_build_cache/v1/`: + +- Go fingerprints cover the target-specific `go list -deps` inputs inside `core/` and `Clash.Meta`, module files, effective + build configuration, build-tool sources, target flags, Go environment/toolchain, and Android NDK compiler details. +- Windows helper fingerprints cover its Rust sources and manifests, Cargo/Rust + toolchains and flags, and the expected Core SHA256. +- A cache hit requires the fingerprint and every recorded output's path, size, and modification state to match. It exits + silently without Go/Cargo compilation, output copying, or Windows `taskkill`. +- Cache records are written only after a successful build and protected by per-target process/file locks. Missing outputs, + changed inputs, cache-schema changes, or `--force` rebuild only the affected target. +- `flutter clean` removes `.dart_tool`, so the next native build performs one full core rebuild. Manual builds can bypass + the cache with `make core- FORCE=1`. + +This differs from `rust_api`: rust_api is a runtime Flutter Rust Bridge integration whose Cargokit hooks produce its native +FFI library, while setup is only the build and packaging bridge for FlClash's external core artifacts. + +Windows helper integrity/version check: + +- The build tool constructs the Core first, calculates its SHA256, and always + builds the Rust Helper with release hardening and that expected hash. +- Flutter does not embed or send the Core SHA256. Debug, Profile, and Release + builds use the same Helper protocol and may use TUN through the same flow. +- `/ping` is loopback-only and requires no request token. The Helper verifies the fixed `FlClashCore.exe` beside it against + its embedded SHA256 before reporting readiness, and repeats verification before every launch. The response includes the + running Helper path and protocol header; Dart checks both against the current installation. +- Flutter creates a 128-bit lowercase-hex session ID and uses it as the random named-pipe suffix. `/start` receives only + that address and session ID, validates the fixed `FlClashCore_` namespace, starts the fixed Core beside the + Helper, and returns the same session ID plus the spawned PID. Flutter verifies both the session and named-pipe peer PID. +- `/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. + +Build configuration defaults live in `build_tool/lib/src/options.dart` and can be overridden via a root `build_config.yaml`. Architecture detection is automatic. The `--description` flag passed to `flutter_distributor` adds arch suffixes to artifact names, such as `FlClash-0.8.93-macos-arm64.dmg`. ## Local Plugins -- `setup`: build harness FFI plugin. +- `setup`: build-time harness for Go core artifacts and the Windows Rust helper; no runtime Dart API. - `proxy`: system proxy configuration. -- `rust_api`: Flutter Rust Bridge FFI plugin. +- `rust_api`: runtime Flutter Rust Bridge FFI plugin built through Cargokit. - `tray_manager`: system tray fork/customization. - `wifi_ssid`: Wi-Fi SSID detection. - `window_ext`: window extensions. @@ -150,7 +334,32 @@ Architecture detection is automatic. The `--description` flag passed to `flutter `services/helper/` is a Windows-only privileged helper for starting the core as admin and managing TUN. It is built with: ```bash -cargo build --release --features windows-service +make core-windows ``` -It uses token-based auth with the Flutter app. +The build tool always compiles the Helper in Rust release mode after calculating +the SHA256 of the Core produced for the active Flutter configuration. + +The helper owns its Windows Service Control Manager lifecycle through two elevated commands: + +- `FlClashHelperService.exe install` stops and removes any stale registration, creates the auto-start service for the + current executable path, starts it, and waits for the running state. +- `FlClashHelperService.exe uninstall` stops the service, waits for shutdown, removes its registration, and is also used + by the Windows package uninstaller. + +The Dart layer only launches the helper's `install` command through `ShellExecuteW`; it does not compose `sc.exe`, +`taskkill`, or `cmd.exe` command lines. + +In every Flutter build mode it opens the fixed Core executable beside the Helper without write/delete sharing, validates +it against the SHA256 embedded only in the Helper, and keeps that handle open through process creation. Protocol version 5 +uses 32-character lowercase-hex session ownership: + +- `GET /ping` verifies Core and returns the current Helper executable path with `x-flclash-helper-protocol`. +- `POST /start` rejects unknown JSON fields, validates `{address, sessionId}`, replaces any previously managed Core, and + returns `{sessionId, pid}`. +- `POST /stop` validates `{sessionId}` and only stops the matching managed Core. A session mismatch is HTTP 409. +- `GET /logs` exposes the bounded recent Helper/Core stderr buffer with `no-store` caching. + +All endpoints bind only to `127.0.0.1:47890` and do not use request-token authentication. Lifecycle safety comes from the +fixed executable/hash, strict pipe namespace, session-scoped stop contract, and Dart-side peer-PID verification. When the +Helper service itself shuts down, it unconditionally stops the Core process it owns. diff --git a/.agents/commands.md b/.agents/commands.md index 6de8c960b1..ab2ada1101 100644 --- a/.agents/commands.md +++ b/.agents/commands.md @@ -33,11 +33,15 @@ make core-macos ARCH=arm64 make core-android TARGET_PLATFORM=android-arm64 ``` +Core builds use setup's input fingerprint cache. Pass `FORCE=1` to bypass it, +for example `make core-macos ARCH=arm64 FORCE=1`. + The Makefile wraps `plugins/setup/buildkit/run_build_tool.sh`; prefer the `make` entry points unless debugging the build tool itself. ## Flutter Development -The project is pinned with FVM. +The project follows FVM's `stable` channel locally. Release CI pins an exact +Flutter version separately; see `.agents/project.md`. ```bash fvm flutter pub get @@ -83,6 +87,7 @@ Tests use `package:test/test.dart` for pure Dart logic and `flutter_test` for pr ```bash flutter test test/models/ flutter test test/core/ +flutter test test/core/desktop/ flutter test test/providers/ flutter test test/common/ flutter test test/database/ @@ -93,9 +98,62 @@ 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`. +For the current Core/service architecture, useful focused checks are: + +```bash +flutter test test/core/desktop/ +flutter test test/core/service_test.dart +flutter test test/core/protocol_contract_test.dart +flutter test test/manager/core_manager_test.dart +flutter test test/providers/action_test.dart test/providers/system_action_test.dart +flutter test test/widgets/core_status_button_test.dart +``` + +What those suites own: + +- `test/core/desktop/`: replaceable IPC transport, RPC request correlation/failure, direct/Helper process leases, and + latest-intent desktop lifecycle convergence. +- `test/core/service_test.dart`: `CoreService` composition and terminal close behavior. +- `test/core/protocol_contract_test.dart`: shared Dart/Go method and event-envelope compatibility, including event batches. +- `test/providers/action_test.dart`: Core start/restart orchestration and overlapping restart requests. +- `test/providers/system_action_test.dart`: ordered, idempotent exit cleanup and watchdog behavior. +- `test/widgets/core_status_button_test.dart`: 600-millisecond connecting presentation hold, immediate failure display, + long-running connecting state, and disconnected restart. + +## Native Component Verification + +The CI Go-wrapper checks can be reproduced without CGO: + +```bash +cd core +CGO_ENABLED=0 go test . +CGO_ENABLED=0 go vet . +``` + +The Windows Helper's loopback/session protocol tests are host-independent by default. Windows CI additionally enables its +service implementation: + +```bash +cargo fmt --manifest-path services/helper/Cargo.toml -- --check +cargo test --manifest-path services/helper/Cargo.toml +cargo test --manifest-path services/helper/Cargo.toml --features windows-service +``` + +The last command requires Windows for meaningful service coverage. Native Android lifecycle edits should at minimum +compile the modules they touch; use JDK 17 in this checkout: + +```bash +cd android +JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew :service:compileDebugKotlin +JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew :app:compileDebugKotlin +``` + +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. + ## Verify -CI runs these in order: +The tag-triggered release workflow runs these root-package checks in order: ```bash flutter pub get @@ -104,3 +162,10 @@ flutter test --reporter expanded ``` Run `flutter analyze` locally before committing when practical. + +The workflow runs only for `v*` tag pushes; pull requests do not trigger it. +Root analysis excludes `plugins/**`, and root tests do not discover nested +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. diff --git a/.agents/project.md b/.agents/project.md index 9ca9529f7c..0d138be12f 100644 --- a/.agents/project.md +++ b/.agents/project.md @@ -4,8 +4,10 @@ FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with ## Version Notes -- `.fvmrc` pins Flutter 3.35.7 for local development. -- CI uses Flutter 3.41.9. These may diverge; trust CI as the source of truth for release builds. +- `.fvmrc` follows the FVM `stable` channel for local development; it does not + pin an immutable Flutter version. +- Release CI pins Flutter 3.44.4. Local `stable` may diverge, so trust the CI + version as the source of truth for release builds. - Dart SDK constraint: `>=3.8.0 <4.0.0`. ## Build Dependencies diff --git a/.agents/rules.md b/.agents/rules.md index fef4630916..414ffb892a 100644 --- a/.agents/rules.md +++ b/.agents/rules.md @@ -21,8 +21,37 @@ Generated directories are excluded from analysis: - `lib/**/generated/**` - `plugins/**` +## Core API Safety + +- 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. + +## Lifecycle Rules + +- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets, + managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`. +- `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must + stay centralized in `SystemAction`/`SystemExitCoordinator`. +- Android start/stop MethodChannel calls are optimistic UI commands. Keep latest-wins arbitration in native + `ServiceState`; do not add a Flutter completion callback that creates a second lifecycle owner. +- Android service callbacks are not automatically user intent. Route explicit Quick Settings, Always-on VPN, and revoke + actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner. +- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the + 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. + ## 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. + 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. @@ -35,10 +64,14 @@ notifier.update((state) => newValue); When testing freezed models with nested objects, always round-trip through `jsonEncode` and `jsonDecode`. Direct `fromJson(toJson())` fails for nested freezed types because `toJson()` stores child objects directly instead of maps. +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. + ## Generated Code Do not manually edit generated files under: +- `lib/l10n/l10n.dart` - `lib/models/generated/` - `lib/providers/generated/` - `lib/database/generated/` diff --git a/.agents/skills.md b/.agents/skills.md index 09ad53e6f5..85196be1ca 100644 --- a/.agents/skills.md +++ b/.agents/skills.md @@ -6,8 +6,9 @@ Repo-scoped Codex skills live under `.agents/skills/*/SKILL.md`. Codex can disco - `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification. - `provider-tests`: Riverpod provider, notifier, and state-management tests. -- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, and user-facing interactions. -- `core-platform`: core integration, platform managers, Go core communication, desktop/mobile behavior, and Windows helper flow. +- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, async feedback, and user-facing interactions. +- `core-platform`: Core lifecycle/process ownership, Android services, Go event delivery, desktop IPC, platform managers, + VPN/TUN, and Windows Helper flow. ## Authoring Notes diff --git a/.agents/skills/core-platform/SKILL.md b/.agents/skills/core-platform/SKILL.md index 02c236120d..92cdc398d2 100644 --- a/.agents/skills/core-platform/SKILL.md +++ b/.agents/skills/core-platform/SKILL.md @@ -1,26 +1,45 @@ --- name: core-platform -description: Use when changing FlClash core integration, platform managers, Go core communication, desktop/mobile platform behavior, or Windows helper flow. +description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, VPN/TUN, or Windows Helper flow. --- # Core And Platform ## When To Use -Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, build hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior. +Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules, build +hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior. ## Workflow -1. Identify which boundary owns the behavior: - - Android lib mode: `lib/core/lib.dart`. - - Desktop process/socket mode: `lib/core/service.dart`. - - Shared facade: `lib/core/controller.dart` and `lib/core/interface.dart`. - - Platform lifecycle: `lib/manager/`. -2. Route feature code through `CoreController` and `CoreHandlerInterface`; avoid direct calls to platform implementations outside their boundary. -3. Keep desktop and mobile paths explicit. -4. For action-layer behavior, inspect `lib/providers/action.dart` and relevant generated providers. -5. Add or update shared Dart tests for logic that can be isolated. -6. Manually verify native behavior when automated coverage is not practical. +1. Identify the authoritative owner before changing behavior: + - Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`. + - Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`. + - Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`. + - Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`. + - Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`. + - Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows Helper HTTP contract: + `lib/core/desktop/helper_client.dart` and `services/helper/`. + - Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; UI/event observation: `lib/manager/`. +2. Trace every entry path into that owner, including UI/provider calls, Quick Settings, notification actions, Always-on VPN, + revoke callbacks, application exit, and crash/disconnect recovery. Lifecycle callbacks are not implicit user intent. +3. Preserve latest-intent semantics: + - Desktop revisions converge to running/restarted/stopped/closed and report applied/coalesced/superseded outcomes. + - Android Flutter calls stay optimistic; `ServiceState` identity-checks the latest native `RunRequest`. +4. Route feature calls through `CoreController` and `CoreHandlerInterface`. Do not bypass desktop process leases or create a + second Android service binding owner. +5. Keep JSON envelopes and event shapes identical across Dart, Go, JNI, and desktop IPC. If event traffic changes, preserve + the separate priority and bulk queues in `core/message.go`. +6. Keep shutdown single-owned and terminal. `SystemExitCoordinator` sequences resource cleanup, window close, Core close, + and process exit; widget/manager disposal must not race it. +7. Add or update focused tests at the narrowest layer, then run the matching commands from `.agents/commands.md`: + - Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`. + - Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`. + - Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`. + - Android Kotlin: compile each touched Gradle module with JDK 17. + - Windows Helper: Cargo format/tests; run the `windows-service` feature on Windows. +8. Explicitly state host gaps. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, and Windows Service + Control Manager behavior need their real platform even when portable tests pass. ## Reference Files @@ -28,6 +47,19 @@ Read `.agents/architecture.md` for the current core modes, manager stack, build ## Pitfalls -- Debug Windows helper auth differs from release token verification. +- Keep the Windows Helper protocol and Core SHA256 validation identical across + Flutter build modes; the Helper owns executable integrity checks. +- Protocol version 5 uses a 32-character lowercase-hex session ID. `/start` must return the submitted session and PID; + `/stop` must never terminate a different session; Dart must verify the connected named-pipe peer PID. +- A desktop process lease with unconfirmed exit must remain owned until cleanup succeeds. Do not discard it and start a + replacement Core. +- `CoreController.close()` is terminal. Do not call it from a reusable manager lifecycle or recover by starting it again. +- `ServiceBroadcastReceiver.goAsync()` must finish once even on timeout; its watchdog releases the broadcast only and must + not become a service timeout. +- Do not interpret service creation/destruction as start/stop intent. Always-on startup is explicit through + `VPN_START_REQUESTED`; revoke is explicit through `VPN_REVOKED`. +- Keep log/request floods from evicting state-bearing Core events. Each queue may evict only its own oldest item. +- Do not expose direct filesystem deletion APIs through Core or helper IPC; use + a scope-specific cleanup API instead. - `plugins/setup/` is a build harness, not a Dart API plugin. - Build hooks can trigger Go or Rust compilation indirectly through Flutter platform builds. diff --git a/.agents/skills/ui-work/SKILL.md b/.agents/skills/ui-work/SKILL.md index c2cab0a23b..2f94062cec 100644 --- a/.agents/skills/ui-work/SKILL.md +++ b/.agents/skills/ui-work/SKILL.md @@ -1,6 +1,6 @@ --- name: ui-work -description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, navigation surfaces, or user-facing interactions. +description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, navigation surfaces, async feedback, or user-facing interactions. --- # UI Work @@ -18,7 +18,12 @@ Use this for user-facing Flutter UI changes in `lib/`, including widgets, screen 5. Prefer `const` constructors and final locals. 6. Localize user-facing text through ARB; use `localization` when text changes are non-trivial. 7. Add focused widget tests when behavior changes, especially for rendering states, taps, scrolling, and empty/error states. -8. Run targeted verification: +8. For asynchronous controls, define separately: + - authoritative provider/domain state; + - display-only state such as a minimum progress duration; + - tap policy while work or display holds are active; + - failure/disposal cleanup, normally in `finally` for animations and timers. +9. Run targeted verification: ```bash flutter analyze @@ -30,3 +35,13 @@ Use this for user-facing Flutter UI changes in `lib/`, including widgets, screen - Do not introduce a new visual system for one screen. - Do not manually edit generated localization or provider files. - Avoid broad layout rewrites unless the requested change requires them. +- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors + bypass them immediately. +- Do not leave loading animations active when callbacks throw. Test the exception path, not only the successful tap. + +## Current Interaction Examples + +- `CoreStatusButton` watches `coreStatusProvider` but keeps its 600-millisecond connecting hold locally. Taps are ignored + during the hold or genuine connecting state; disconnected cancels the hold immediately. +- Proxy delay testing writes `0` while pending, the measured delay on success, and `-1` on failure. `DelayTestButton` resets + its animation in `finally`. diff --git a/.github/scripts/generate_release_notes.sh b/.github/scripts/generate_release_notes.sh new file mode 100644 index 0000000000..25fa71a6e9 --- /dev/null +++ b/.github/scripts/generate_release_notes.sh @@ -0,0 +1,28 @@ +#!/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 new file mode 100644 index 0000000000..82a683afb8 --- /dev/null +++ b/.github/scripts/generate_release_notes_test.sh @@ -0,0 +1,66 @@ +#!/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 a33fba3d89..0078a1e476 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -18,6 +18,10 @@ jobs: with: submodules: recursive + - name: Test release notes generation + shell: bash + run: bash .github/scripts/generate_release_notes_test.sh + - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -34,8 +38,73 @@ jobs: - name: Run tests run: flutter test --reporter expanded + - 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 + ) + + - name: Validate setup build tool + working-directory: plugins/setup/buildkit/build_tool + run: | + dart pub get + dart analyze + dart test + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.4' + cache-dependency-path: core/go.sum + + - name: Validate Go core wrapper + working-directory: core + env: + CGO_ENABLED: 0 + run: | + go test . + go vet . + + - name: Validate Rust components + run: | + cargo fmt --manifest-path services/helper/Cargo.toml -- --check + cargo test --manifest-path services/helper/Cargo.toml + cargo fmt --manifest-path plugins/rust_api/rust/Cargo.toml -- --check + cargo test --manifest-path plugins/rust_api/rust/Cargo.toml + + windows-helper-test: + name: Windows helper test + runs-on: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check format + run: cargo fmt --manifest-path services/helper/Cargo.toml -- --check + + - name: Run Windows service tests + run: cargo test --manifest-path services/helper/Cargo.toml --features windows-service + build: - needs: [ test ] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: [ test, windows-helper-test ] runs-on: ${{ matrix.os }} strategy: matrix: @@ -149,18 +218,19 @@ jobs: - name: Generate run: | last_ver=$(grep -m1 '^## ' CHANGELOG.md 2>/dev/null | sed 's/^## //') - - tags=($(git tag --merged HEAD --sort=-creatordate)) - - temp="NEW_CHANGELOG.md" > "$temp" - + + 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" @@ -215,25 +285,13 @@ jobs: merge-multiple: true - name: Generate release.md + shell: bash run: | - tags=($(git tag --merged HEAD --sort=-creatordate)) - preTag=$(curl -s "https://api.github.com/repos/chen08209/FlClash/releases/latest" | \ - sed -nE 's/.*"tag_name": "([^"]+)".*/\1/p') - - [ -z "$preTag" ] && preTag="" - - out="release.md" > "$out" - - for i in "${!tags[@]}"; do - curr="${tags[i]}" - [[ "$curr" == "$preTag" ]] && break - - prev="${tags[i+1]}" - range="${prev:+$prev..}$curr" - - git log --no-merges --pretty=format:"%B" "$range" | \ - awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$out" - done + 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 - name: Push to telegram env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} diff --git a/.gitignore b/.gitignore index c72a837e86..c3bd0c480c 100644 --- a/.gitignore +++ b/.gitignore @@ -74,4 +74,5 @@ devtools_options.yaml # FVM Version Cache .fvm/ -.fvmrc \ No newline at end of file +.fvmrc +/coverage/lcov.info diff --git a/AGENTS.md b/AGENTS.md index 9a5554412a..f61c913ecd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,9 +21,18 @@ Read these only when the task touches their area: ## Highest Priority Rules +- When the user explicitly requests a scoped, low-risk change, inspect the relevant context and implement it directly. + Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation. + Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result. - Use `flutter test`, not `dart test`, because models pull in Flutter types. - Run code generation after modifying models, providers, or database schema. - Do not manually edit generated files. +- Preserve lifecycle ownership: desktop Core process convergence belongs to `lib/core/desktop/`; Android service intent + arbitration belongs to `ServiceState`. UI/provider code may request a transition but must not become a second source of + truth. +- 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. - 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/Makefile b/Makefile index c1bef1dbb1..b97e9d4423 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ PLATFORM ?= macos BUILDKIT := plugins/setup/buildkit/run_build_tool.sh ARCH_ARG := $(if $(ARCH),--arch $(ARCH),) TARGET_PLATFORM_ARG := $(if $(TARGET_PLATFORM),--target-platform $(TARGET_PLATFORM),) +FORCE_ARG := $(if $(filter 1 true yes,$(FORCE)),--force,) .PHONY: help submodules core core-macos core-linux core-windows core-android @@ -13,12 +14,13 @@ help: @echo 'make core-macos ARCH=arm64' @echo 'make core-android ARCH=arm64' @echo 'make core-android TARGET_PLATFORM=android-arm64' + @echo 'make core-macos FORCE=1 # bypass setup build cache' submodules: git submodule update --init --recursive core: - bash $(BUILDKIT) $(PLATFORM) $(ARCH_ARG) $(TARGET_PLATFORM_ARG) + bash $(BUILDKIT) $(PLATFORM) $(ARCH_ARG) $(TARGET_PLATFORM_ARG) $(FORCE_ARG) core-macos: $(MAKE) core PLATFORM=macos diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b64ccea8e6..5d37e8851f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -15,21 +15,20 @@ val localProperties = Properties().apply { } } -val mStoreFile: File = file("keystore.jks") -val mStorePassword: String? = localProperties.getProperty("storePassword") -val mKeyAlias: String? = localProperties.getProperty("keyAlias") -val mKeyPassword: String? = localProperties.getProperty("keyPassword") -val isRelease = - mStoreFile.exists() && mStorePassword != null && mKeyAlias != null && mKeyPassword != null - +val releaseStoreFile = file("keystore.jks") +val releaseStorePassword = localProperties.getProperty("storePassword") +val releaseKeyAlias = localProperties.getProperty("keyAlias") +val releaseKeyPassword = localProperties.getProperty("keyPassword") +val hasReleaseSigning = releaseStoreFile.exists() && + releaseStorePassword != null && + releaseKeyAlias != null && + releaseKeyPassword != null android { namespace = "com.follow.clash" compileSdk = libs.versions.compileSdk.get().toInt() ndkVersion = libs.versions.ndkVersion.get() - - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -44,12 +43,12 @@ android { } signingConfigs { - if (isRelease) { + if (hasReleaseSigning) { create("release") { - storeFile = mStoreFile - storePassword = mStorePassword - keyAlias = mKeyAlias - keyPassword = mKeyPassword + storeFile = releaseStoreFile + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword } } } @@ -69,7 +68,7 @@ android { release { isMinifyEnabled = true isShrinkResources = true - if (isRelease) { + if (hasReleaseSigning) { signingConfig = signingConfigs.getByName("release") } else { signingConfig = signingConfigs.getByName("debug") @@ -77,7 +76,8 @@ android { } proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", ) } } @@ -93,10 +93,10 @@ flutter { source = "../.." } - dependencies { implementation(project(":service")) implementation(project(":common")) + implementation(project(":core")) implementation(libs.core.splashscreen) implementation(libs.gson) implementation(libs.smali.dexlib2) { diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 0b65f1f717..7eb34736a7 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1,4 +1,4 @@ --keep class com.follow.clash.models.**{ *; } +-keep class com.follow.clash.models.** { *; } --keep class com.follow.clash.service.models.**{ *; } \ No newline at end of file +-keep class com.follow.clash.service.models.** { *; } diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index d9270beb6e..1fb7cc8287 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,9 +1,5 @@ - + - @@ -23,15 +25,14 @@ tools:ignore="QueryAllPackagesPermission" /> - + android:label="@string/app_name"> @@ -90,7 +91,7 @@ android:name=".TileService" android:exported="true" android:icon="@drawable/ic" - android:label="FlClash" + android:label="@string/app_name" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> @@ -101,13 +102,13 @@ - - + + @@ -115,4 +116,4 @@ android:name="flutterEmbedding" android:value="2" /> - \ No newline at end of file + diff --git a/android/app/src/main/kotlin/com/follow/clash/BroadcastReceiver.kt b/android/app/src/main/kotlin/com/follow/clash/BroadcastReceiver.kt deleted file mode 100644 index 93e621e48e..0000000000 --- a/android/app/src/main/kotlin/com/follow/clash/BroadcastReceiver.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.follow.clash - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import com.follow.clash.common.BroadcastAction -import com.follow.clash.common.GlobalState -import com.follow.clash.common.action -import kotlinx.coroutines.launch - -class BroadcastReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - BroadcastAction.SERVICE_CREATED.action -> { - GlobalState.log("Receiver service created") - GlobalState.launch { - State.handleStartServiceAction() - } - } - - BroadcastAction.SERVICE_DESTROYED.action -> { - GlobalState.log("Receiver service destroyed") - GlobalState.launch { - State.handleStopServiceAction() - } - } - } - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/follow/clash/Ext.kt b/android/app/src/main/kotlin/com/follow/clash/Ext.kt index 7cabf5b829..44247d1d55 100644 --- a/android/app/src/main/kotlin/com/follow/clash/Ext.kt +++ b/android/app/src/main/kotlin/com/follow/clash/Ext.kt @@ -17,37 +17,32 @@ import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.util.concurrent.TimeUnit -import kotlin.coroutines.resume private const val ICON_TTL_DAYS = 1L val Application.sharedState: SharedState - get() { - try { - val sp = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE) - val res = sp.getString("flutter.sharedState", "") - return Gson().fromJson(res, SharedState::class.java) - } catch (_: Exception) { - return SharedState() - } + get() = try { + val preferences = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE) + val json = preferences.getString("flutter.sharedState", null) + Gson().fromJson(json, SharedState::class.java) ?: SharedState() + } catch (_: Exception) { + SharedState() } - private var lastToast: Toast? = null fun Application.showToast(text: String?) { + if (text.isNullOrEmpty()) return Handler(Looper.getMainLooper()).post { lastToast?.cancel() lastToast = Toast.makeText(this, text, Toast.LENGTH_LONG).apply { show() } } - } suspend fun PackageManager.getPackageIconPath(packageName: String): String = @@ -55,8 +50,7 @@ suspend fun PackageManager.getPackageIconPath(packageName: String): String = val cacheDir = GlobalState.application.cacheDir val iconDir = File(cacheDir, "icons").apply { mkdirs() } return@withContext try { - val pkgInfo = getPackageInfo(packageName, 0) - val lastUpdateTime = pkgInfo.lastUpdateTime + val lastUpdateTime = getPackageInfo(packageName, 0).lastUpdateTime val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp") if (iconFile.exists() && !isExpired(iconFile)) { return@withContext iconFile.absolutePath @@ -80,14 +74,11 @@ private suspend fun saveDrawableToFile(drawable: Drawable, file: File) { drawable.toBitmap(width = 128, height = 128) } try { - val format = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { - Bitmap.CompressFormat.WEBP_LOSSY - } - - else -> { - Bitmap.CompressFormat.WEBP - } + @Suppress("DEPRECATION") + val format = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + Bitmap.CompressFormat.WEBP } FileOutputStream(file).use { fos -> bitmap.compress(format, 90, fos) @@ -103,48 +94,12 @@ private fun isExpired(file: File): Boolean { return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS) } -suspend fun MethodChannel.awaitResult( - method: String, arguments: Any? = null -): T? = withContext(Dispatchers.Main) { - suspendCancellableCoroutine { continuation -> - invokeMethod(method, arguments, object : MethodChannel.Result { - override fun success(result: Any?) { - @Suppress("UNCHECKED_CAST") continuation.resume(result as T?) - } - - override fun error(code: String, message: String?, details: Any?) { - continuation.resume(null) - } - - override fun notImplemented() { - continuation.resume(null) - } - }) - } -} - inline fun FlutterEngine.plugin(): T? { return plugins.get(T::class.java) as T? } -fun MethodChannel.invokeMethodOnMainThread( - method: String, arguments: Any? = null, callback: ((Result) -> Unit)? = null -) { +fun MethodChannel.invokeMethodOnMainThread(method: String, arguments: Any? = null) { Handler(Looper.getMainLooper()).post { - invokeMethod(method, arguments, object : MethodChannel.Result { - override fun success(result: Any?) { - @Suppress("UNCHECKED_CAST") callback?.invoke(Result.success(result as T)) - } - - override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { - val exception = Exception("MethodChannel error: $errorCode - $errorMessage") - callback?.invoke(Result.failure(exception)) - } - - override fun notImplemented() { - val exception = NotImplementedError("Method not implemented: $method") - callback?.invoke(Result.failure(exception)) - } - }) + invokeMethod(method, arguments) } } diff --git a/android/app/src/main/kotlin/com/follow/clash/Application.kt b/android/app/src/main/kotlin/com/follow/clash/FlClashApplication.kt similarity index 85% rename from android/app/src/main/kotlin/com/follow/clash/Application.kt rename to android/app/src/main/kotlin/com/follow/clash/FlClashApplication.kt index a3320b796c..1b904458e0 100644 --- a/android/app/src/main/kotlin/com/follow/clash/Application.kt +++ b/android/app/src/main/kotlin/com/follow/clash/FlClashApplication.kt @@ -4,8 +4,7 @@ import android.app.Application import android.content.Context import com.follow.clash.common.GlobalState -class Application : Application() { - +class FlClashApplication : Application() { override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) GlobalState.init(this) diff --git a/android/app/src/main/kotlin/com/follow/clash/MainActivity.kt b/android/app/src/main/kotlin/com/follow/clash/MainActivity.kt index c5760d2874..e8cb6e90ba 100644 --- a/android/app/src/main/kotlin/com/follow/clash/MainActivity.kt +++ b/android/app/src/main/kotlin/com/follow/clash/MainActivity.kt @@ -1,37 +1,22 @@ package com.follow.clash -import android.os.Bundle -import com.follow.clash.common.GlobalState import com.follow.clash.plugins.AppPlugin import com.follow.clash.plugins.ServicePlugin import com.follow.clash.plugins.TilePlugin import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch - -class MainActivity : FlutterActivity(), - CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - } +class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) flutterEngine.plugins.add(AppPlugin()) flutterEngine.plugins.add(ServicePlugin()) flutterEngine.plugins.add(TilePlugin()) - State.flutterEngine = flutterEngine + ServiceState.attachFlutterEngine(flutterEngine) } override fun onDestroy() { - GlobalState.launch { - Service.setEventListener(null) - } - State.flutterEngine = null + flutterEngine?.let(ServiceState::detachFlutterEngine) super.onDestroy() } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/com/follow/clash/QuickActionActivity.kt b/android/app/src/main/kotlin/com/follow/clash/QuickActionActivity.kt new file mode 100644 index 0000000000..f2d613edd7 --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/QuickActionActivity.kt @@ -0,0 +1,28 @@ +package com.follow.clash + +import android.app.Activity +import android.os.Bundle +import androidx.core.content.pm.ShortcutManagerCompat +import com.follow.clash.common.GlobalState +import com.follow.clash.common.QuickAction +import com.follow.clash.common.action +import kotlinx.coroutines.launch + +class QuickActionActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + when (intent.action) { + QuickAction.START.action -> GlobalState.launch { ServiceState.handleStartAction() } + QuickAction.STOP.action -> GlobalState.launch { ServiceState.handleStopAction() } + QuickAction.TOGGLE.action -> { + ShortcutManagerCompat.reportShortcutUsed(this, SHORTCUT_ID) + GlobalState.launch { ServiceState.handleToggleAction() } + } + } + finish() + } + + private companion object { + const val SHORTCUT_ID = "toggle" + } +} diff --git a/android/app/src/main/kotlin/com/follow/clash/Service.kt b/android/app/src/main/kotlin/com/follow/clash/Service.kt deleted file mode 100644 index 9385b550cc..0000000000 --- a/android/app/src/main/kotlin/com/follow/clash/Service.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.follow.clash - -import com.follow.clash.common.GlobalState -import com.follow.clash.common.ServiceDelegate -import com.follow.clash.common.formatString -import com.follow.clash.common.intent -import com.follow.clash.service.IAckInterface -import com.follow.clash.service.ICallbackInterface -import com.follow.clash.service.IEventInterface -import com.follow.clash.service.IRemoteInterface -import com.follow.clash.service.IResultInterface -import com.follow.clash.service.IVoidInterface -import com.follow.clash.service.RemoteService -import com.follow.clash.service.models.NotificationParams -import com.follow.clash.service.models.VpnOptions -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -object Service { - private val delegate by lazy { - ServiceDelegate( - RemoteService::class.intent, ::handleServiceDisconnected - ) { - IRemoteInterface.Stub.asInterface(it) - } - } - - var onServiceDisconnected: ((String) -> Unit)? = null - - private fun handleServiceDisconnected(message: String) { - onServiceDisconnected?.let { - it(message) - } - } - - fun bind() { - delegate.bind() - } - - fun unbind() { - delegate.unbind() - } - - suspend fun invokeAction(data: String, cb: ((result: String) -> Unit)?): Result { - val res = mutableListOf() - return delegate.useService { - it.invokeAction( - data, object : ICallbackInterface.Stub() { - override fun onResult( - result: ByteArray?, isSuccess: Boolean, ack: IAckInterface? - ) { - res.add(result ?: byteArrayOf()) - ack?.onAck() - if (isSuccess) { - cb?.let { cb -> - cb(res.formatString()) - } - } - } - }) - } - } - - suspend fun quickSetup( - initParamsString: String, - setupParamsString: String, - onStarted: (() -> Unit)?, - onResult: ((result: String) -> Unit)?, - ): Result { - val res = mutableListOf() - return delegate.useService { - it.quickSetup( - initParamsString, - setupParamsString, - object : ICallbackInterface.Stub() { - override fun onResult( - result: ByteArray?, isSuccess: Boolean, ack: IAckInterface? - ) { - res.add(result ?: byteArrayOf()) - ack?.onAck() - if (isSuccess) { - onResult?.let { cb -> - cb(res.formatString()) - } - } - } - }, - object : IVoidInterface.Stub() { - override fun invoke() { - onStarted?.let { onStarted -> - onStarted() - } - } - } - ) - } - } - - suspend fun setEventListener( - cb: ((result: String?) -> Unit)? - ): Result { - val results = HashMap>() - return delegate.useService { - it.setEventListener( - when (cb != null) { - true -> object : IEventInterface.Stub() { - override fun onEvent( - id: String, data: ByteArray?, isSuccess: Boolean, ack: IAckInterface? - ) { - if (results[id] == null) { - results[id] = mutableListOf() - } - results[id]?.add(data ?: byteArrayOf()) - ack?.onAck() - if (isSuccess) { - cb(results[id]?.formatString()) - results.remove(id) - } - } - } - - false -> null - }) - } - } - - suspend fun updateNotificationParams( - params: NotificationParams - ): Result { - return delegate.useService { - it.updateNotificationParams(params) - } - } - - suspend fun setCrashlytics( - enable: Boolean - ): Result { - return delegate.useService { - it.setCrashlytics(enable) - } - } - - private suspend fun awaitIResultInterface( - block: (IResultInterface) -> Unit - ): Long = suspendCancellableCoroutine { continuation -> - val callback = object : IResultInterface.Stub() { - override fun onResult(time: Long) { - if (continuation.isActive) { - continuation.resume(time) - } - } - } - - try { - block(callback) - } catch (e: Exception) { - GlobalState.log("awaitIResultInterface $e") - if (continuation.isActive) { - continuation.resumeWithException(e) - } - } - } - - - suspend fun startService(options: VpnOptions, runTime: Long): Long { - return delegate.useService { - awaitIResultInterface { callback -> - it.startService(options, runTime, callback) - } - }.getOrNull() ?: 0L - } - - suspend fun stopService(): Long { - return delegate.useService { - awaitIResultInterface { callback -> - it.stopService(callback) - } - }.getOrNull() ?: 0L - } - - suspend fun getRunTime(): Long { - return delegate.useService { - it.runTime - }.getOrNull() ?: 0L - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/follow/clash/ServiceBroadcastReceiver.kt b/android/app/src/main/kotlin/com/follow/clash/ServiceBroadcastReceiver.kt new file mode 100644 index 0000000000..b92b808da7 --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/ServiceBroadcastReceiver.kt @@ -0,0 +1,58 @@ +package com.follow.clash + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import com.follow.clash.common.BroadcastAction +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 timeout = Runnable { + if (finished.compareAndSet(false, true)) { + GlobalState.log("Broadcast handling timed out: $action") + pendingResult.finish() + } + } + mainHandler.postDelayed(timeout, BROADCAST_TIMEOUT_MILLIS) + GlobalState.launch { + try { + handleAction(action) + } catch (error: Exception) { + GlobalState.log("Unable to handle service broadcast $action: $error") + } finally { + mainHandler.removeCallbacks(timeout) + if (finished.compareAndSet(false, true)) { + pendingResult.finish() + } + } + } + } + + private suspend fun handleAction(action: String) { + when (action) { + BroadcastAction.VPN_START_REQUESTED.action -> { + GlobalState.log("System requested VPN service start") + ServiceState.handleStartAction() + } + + BroadcastAction.VPN_REVOKED.action -> { + GlobalState.log("VPN permission revoked") + ServiceState.handleVpnRevokeAction() + } + } + } + + companion object { + private const val BROADCAST_TIMEOUT_MILLIS = 9_000L + private val mainHandler = Handler(Looper.getMainLooper()) + } +} diff --git a/android/app/src/main/kotlin/com/follow/clash/ServiceController.kt b/android/app/src/main/kotlin/com/follow/clash/ServiceController.kt new file mode 100644 index 0000000000..4cec8639b8 --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/ServiceController.kt @@ -0,0 +1,227 @@ +package com.follow.clash + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import com.follow.clash.common.GlobalState +import com.follow.clash.common.intent +import com.follow.clash.core.Core +import com.follow.clash.service.ManagedService +import com.follow.clash.service.ProxyService +import com.follow.clash.service.ServiceConfig +import com.follow.clash.service.VpnService +import com.follow.clash.service.models.VpnOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.resume + +object ServiceController { + private val lock = Mutex() + private var binding: ManagedServiceBinding? = null + @Volatile + private var runTimeMillis = 0L + + suspend fun unbind() = lock.withLock { + clearBinding() + } + + private fun clearBinding() { + binding?.unbind() + binding = null + } + + fun invokeMethod(data: String, callback: ((String) -> Unit)?): Result = runCatching { + Core.invokeMethod(data) { result -> + callback?.invoke(result.orEmpty()) + } + } + + suspend fun quickSetup( + initParams: String, + setupParams: String, + ): Result = runCatching { + suspendCancellableCoroutine { continuation -> + Core.quickSetup(initParams, setupParams) { result -> + continuation.resume(result.orEmpty()) + } + } + } + + fun setEventListener(callback: ((String?) -> Unit)?): Result = runCatching { + Core.updateEventListener(callback) + } + + suspend fun start(options: VpnOptions, previousRunTimeMillis: Long): Long = lock.withLock { + ServiceConfig.updateVpnOptions(options) + val nextIntent = if (options.enable) { + VpnService::class.intent + } else { + ProxyService::class.intent + } + + if (binding?.component != nextIntent.component) { + clearBinding() + lateinit var nextBinding: ManagedServiceBinding + nextBinding = ManagedServiceBinding(nextIntent) { message -> + handleServiceDisconnected(nextBinding, message) + } + binding = nextBinding + nextBinding.bind().onFailure { error -> + GlobalState.log("Unable to bind background service: $error") + clearBinding() + runTimeMillis = 0L + return@withLock runTimeMillis + } + } + + val currentBinding = binding ?: return@withLock 0L + val result = currentBinding.useService { service -> service.start() } + if (result.isFailure) { + GlobalState.log("Unable to start background service: ${result.exceptionOrNull()}") + currentBinding.stopIfConnected() + .onFailure { error -> + GlobalState.log("Unable to clean up failed background service start: $error") + } + clearBinding() + runTimeMillis = 0L + return@withLock runTimeMillis + } + + runTimeMillis = previousRunTimeMillis.takeIf { it != 0L } + ?: System.currentTimeMillis() + runTimeMillis + } + + suspend fun stop(): Long = lock.withLock { + binding?.useService { service -> service.stop() } + ?.onFailure { error -> + GlobalState.log("Unable to stop background service: $error") + } + clearBinding() + runTimeMillis = 0L + runTimeMillis + } + + suspend fun isVpnServiceActive(): Boolean = lock.withLock { + runTimeMillis != 0L && binding?.component == VpnService::class.intent.component + } + + fun getRunTimeMillis(): Long = runTimeMillis + + private fun handleServiceDisconnected( + disconnectedBinding: ManagedServiceBinding, + message: String, + ) { + GlobalState.launch { + lock.withLock { + if (binding !== disconnectedBinding) { + return@withLock + } + GlobalState.log("Background service disconnected: $message") + clearBinding() + runTimeMillis = 0L + } + } + } +} + +private class ManagedServiceBinding( + private val intent: Intent, + private val onDisconnected: (String) -> Unit, +) : ServiceConnection { + val component: ComponentName? + get() = intent.component + + private val serviceState = MutableStateFlow?>(null) + + @Volatile + private var isBound = false + + suspend fun bind(): Result = runCatching { + withContext(Dispatchers.Main.immediate) { + serviceState.value = null + isBound = GlobalState.application.bindService( + intent, + this@ManagedServiceBinding, + Context.BIND_AUTO_CREATE, + ) + check(isBound) { "bindService() failed" } + } + } + + suspend fun useService( + connectionTimeoutMillis: Long = 5_000, + block: suspend (ManagedService) -> R, + ): Result = runCatching { + val service = withTimeout(connectionTimeoutMillis) { + serviceState.filterNotNull().first().getOrThrow() + } + withContext(Dispatchers.Default) { + block(service) + } + } + + suspend fun stopIfConnected(): Result = runCatching { + val service = serviceState.value?.getOrNull() ?: return@runCatching + withContext(Dispatchers.Default) { + service.stop() + } + } + + fun unbind() { + serviceState.value = null + if (!isBound) return + isBound = false + Handler(Looper.getMainLooper()).post { + runCatching { + GlobalState.application.unbindService(this) + }.onFailure { error -> + GlobalState.log("Unable to unbind background service: $error") + } + } + } + + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + runCatching { + when (binder) { + is VpnService.LocalBinder -> binder.service + is ProxyService.LocalBinder -> binder.service + null -> error("Binder is empty") + else -> error("Unsupported service binder: ${binder.javaClass.name}") + } + }.onSuccess { service -> + serviceState.value = Result.success(service) + }.onFailure { error -> + disconnect(error.message.orEmpty()) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + disconnect("Service disconnected") + } + + override fun onBindingDied(name: ComponentName?) { + disconnect("Service binding died") + } + + override fun onNullBinding(name: ComponentName?) { + disconnect("Service returned an empty binder") + } + + private fun disconnect(message: String) { + serviceState.value = Result.failure(IllegalStateException(message)) + onDisconnected(message) + } +} diff --git a/android/app/src/main/kotlin/com/follow/clash/ServiceState.kt b/android/app/src/main/kotlin/com/follow/clash/ServiceState.kt new file mode 100644 index 0000000000..303b4a3879 --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/ServiceState.kt @@ -0,0 +1,299 @@ +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() + + var runTimeMillis = 0L + private set + + 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() = transitionLock.withLock { + runTimeMillis = ServiceController.getRunTimeMillis() + mutableRunState.value = if (runTimeMillis == 0L) RunState.STOPPED else RunState.STARTED + } + + suspend fun handleStartAction() { + if (isRunningRequested()) { + return + } + if (flutterEngine != null) { + tilePlugin?.handleStart() + return + } + loadPreferencesAndStart() + } + + suspend fun handleStopAction() { + if (!isRunningRequested()) { + return + } + if (flutterEngine != null) { + tilePlugin?.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), + ) + } + } + } + appPlugin?.requestNotificationPermission(launchRequest) ?: 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 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) + } + } + } + + private fun applySharedState() { + GlobalState.setCrashlytics(sharedState.crashlytics) + ServiceConfig.updateNotificationParams( + NotificationParams( + title = sharedState.currentProfileName, + stopText = sharedState.stopText, + onlyStatisticsProxy = sharedState.onlyStatisticsProxy, + ), + ) + } + + 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 + }, + ) + } + + private fun showConfigError(message: String?) { + GlobalState.application.showToast( + message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE, + ) + } + + 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 + } + + transitionLock.withLock transition@{ + if (!isCurrent(request)) { + return@transition false + } + if (runState.value == RunState.STARTED && runTimeMillis != 0L) { + return@transition true + } + mutableRunState.value = RunState.STARTING + runTimeMillis = ServiceController.start(options, runTimeMillis) + mutableRunState.value = + if (runTimeMillis == 0L) RunState.STOPPED else RunState.STARTED + if (runTimeMillis == 0L) { + fail(request) + return@transition false + } + isCurrent(request) + } + } + + 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 + runTimeMillis = ServiceController.stop() + mutableRunState.value = RunState.STOPPED + isCurrent(request) + } + + 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) + } + } + + private fun createRequest(running: Boolean): RunRequest = + RunRequest(running).also(latestRequest::set) + + private fun isRunningRequested(): Boolean = latestRequest.get().running + + private fun isCurrent(request: RunRequest): Boolean = latestRequest.get() === request + + private fun fail(request: RunRequest) { + latestRequest.compareAndSet(request, RunRequest(running = false)) + } + + private class RunRequest( + val running: Boolean, + ) +} diff --git a/android/app/src/main/kotlin/com/follow/clash/State.kt b/android/app/src/main/kotlin/com/follow/clash/State.kt deleted file mode 100644 index f3e1b472cd..0000000000 --- a/android/app/src/main/kotlin/com/follow/clash/State.kt +++ /dev/null @@ -1,205 +0,0 @@ -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.models.NotificationParams -import com.google.gson.Gson -import io.flutter.embedding.engine.FlutterEngine -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -enum class RunState { - START, PENDING, STOP -} - - -object State { - - val runLock = Mutex() - - var runTime: Long = 0 - - var sharedState: SharedState = SharedState() - - val runStateFlow: MutableStateFlow = MutableStateFlow(RunState.STOP) - - var flutterEngine: FlutterEngine? = null - - val appPlugin: AppPlugin? - get() = flutterEngine?.plugin() - - val tilePlugin: TilePlugin? - get() = flutterEngine?.plugin() - - suspend fun handleToggleAction() { - var action: (suspend () -> Unit)? - runLock.withLock { - action = when (runStateFlow.value) { - RunState.PENDING -> null - RunState.START -> ::handleStopServiceAction - RunState.STOP -> ::handleStartServiceAction - } - } - action?.invoke() - } - - suspend fun handleSyncState() { - runLock.withLock { - try { - Service.bind() - runTime = Service.getRunTime() - val runState = when (runTime == 0L) { - true -> RunState.STOP - false -> RunState.START - } - runStateFlow.tryEmit(runState) - } catch (_: Exception) { - runStateFlow.tryEmit(RunState.STOP) - } - } - } - - suspend fun handleStartServiceAction() { - runLock.withLock { - if (runStateFlow.value != RunState.STOP) { - return - } - tilePlugin?.handleStart() - if (flutterEngine != null) { - return - } - startServiceWithPref() - } - - } - - suspend fun handleStopServiceAction() { - runLock.withLock { - if (runStateFlow.value != RunState.START) { - return - } - tilePlugin?.handleStop() - if (flutterEngine != null) { - return - } - GlobalState.application.showToast(sharedState.stopTip) - handleStopService() - } - } - - fun handleStartService() { - val appPlugin = flutterEngine?.plugin() - if (appPlugin != null) { - appPlugin.requestNotificationsPermission { - startService() - } - return - } - startService() - } - - private fun startServiceWithPref() { - GlobalState.launch { - runLock.withLock { - if (runStateFlow.value != RunState.STOP) { - return@launch - } - sharedState = GlobalState.application.sharedState - setupAndStart() - } - } - } - - suspend fun syncState() { - GlobalState.setCrashlytics(sharedState.crashlytics) - Service.updateNotificationParams( - NotificationParams( - title = sharedState.currentProfileName, - stopText = sharedState.stopText, - onlyStatisticsProxy = sharedState.onlyStatisticsProxy - ) - ) - Service.setCrashlytics(sharedState.crashlytics) - } - - private suspend fun setupAndStart() { - Service.bind() - syncState() - GlobalState.application.showToast(sharedState.startTip) - val initParams = mutableMapOf() - initParams["home-dir"] = GlobalState.application.filesDir.path - initParams["version"] = android.os.Build.VERSION.SDK_INT - val initParamsString = Gson().toJson(initParams) - val setupParamsString = Gson().toJson(sharedState.setupParams) - Service.quickSetup( - initParamsString, - setupParamsString, - onStarted = { - startService() - }, - onResult = { - if (it.isNotEmpty()) { - GlobalState.application.showToast(it) - } - }, - ) - } - - private fun startService() { - GlobalState.launch { - runLock.withLock { - if (runStateFlow.value != RunState.STOP) { - return@launch - } - try { - runStateFlow.tryEmit(RunState.PENDING) - val options = sharedState.vpnOptions ?: return@launch - appPlugin?.let { - it.prepare(options.enable) { - runTime = Service.startService(options, runTime) - runStateFlow.tryEmit(RunState.START) - } - } ?: run { - val intent = VpnService.prepare(GlobalState.application) - if (intent != null) { - return@launch - } - runTime = Service.startService(options, runTime) - runStateFlow.tryEmit(RunState.START) - } - } finally { - if (runStateFlow.value == RunState.PENDING) { - runStateFlow.tryEmit(RunState.STOP) - } - } - } - } - } - - fun handleStopService() { - GlobalState.launch { - runLock.withLock { - if (runStateFlow.value != RunState.START) { - return@launch - } - try { - runStateFlow.tryEmit(RunState.PENDING) - runTime = Service.stopService() - runStateFlow.tryEmit(RunState.STOP) - } finally { - if (runStateFlow.value == RunState.PENDING) { - runStateFlow.tryEmit(RunState.START) - } - } - } - } - } -} - - - diff --git a/android/app/src/main/kotlin/com/follow/clash/TempActivity.kt b/android/app/src/main/kotlin/com/follow/clash/TempActivity.kt deleted file mode 100644 index 035fa3ebb0..0000000000 --- a/android/app/src/main/kotlin/com/follow/clash/TempActivity.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.follow.clash - -import android.app.Activity -import android.os.Bundle -import com.follow.clash.common.QuickAction -import com.follow.clash.common.action -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch - -class TempActivity : Activity(), - CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - when (intent.action) { - QuickAction.START.action -> { - launch { - State.handleStartServiceAction() - } - } - - QuickAction.STOP.action -> { - launch { - State.handleStopServiceAction() - } - } - - QuickAction.TOGGLE.action -> { - launch { - State.handleToggleAction() - } - } - } - finish() - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/follow/clash/TileService.kt b/android/app/src/main/kotlin/com/follow/clash/TileService.kt index 8fb08bfd3f..b522c22c56 100644 --- a/android/app/src/main/kotlin/com/follow/clash/TileService.kt +++ b/android/app/src/main/kotlin/com/follow/clash/TileService.kt @@ -3,7 +3,6 @@ package com.follow.clash import android.annotation.SuppressLint import android.os.Build import android.service.quicksettings.Tile -import android.service.quicksettings.TileService import com.follow.clash.common.QuickAction import com.follow.clash.common.quickIntent import com.follow.clash.common.toPendingIntent @@ -13,49 +12,50 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -class TileService : TileService() { +class TileService : android.service.quicksettings.TileService() { private var scope: CoroutineScope? = null - private fun updateTile(runState: RunState) { - if (qsTile != null) { - qsTile.state = when (runState) { - RunState.START -> Tile.STATE_ACTIVE - RunState.PENDING -> Tile.STATE_UNAVAILABLE - RunState.STOP -> Tile.STATE_INACTIVE - } - qsTile.updateTile() - } - } override fun onStartListening() { super.onStartListening() scope?.cancel() - scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - scope?.launch { - State.handleSyncState() - State.runStateFlow.collect { - updateTile(it) + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { scope -> + scope.launch { + ServiceState.refresh() + ServiceState.runState.collect(::updateTile) } } } - @SuppressLint("StartActivityAndCollapseDeprecated") - private fun handleToggle() { - val intent = QuickAction.TOGGLE.quickIntent - val pendingIntent = intent.toPendingIntent - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startActivityAndCollapse(pendingIntent) - } else { - @Suppress("DEPRECATION") startActivityAndCollapse(intent) - } - } - override fun onClick() { super.onClick() - handleToggle() + openQuickAction() } override fun onStopListening() { scope?.cancel() + scope = null super.onStopListening() } -} \ No newline at end of file + + private fun updateTile(runState: RunState) { + qsTile?.apply { + state = when (runState) { + RunState.STARTED -> Tile.STATE_ACTIVE + RunState.STARTING, RunState.STOPPING -> Tile.STATE_UNAVAILABLE + RunState.STOPPED -> Tile.STATE_INACTIVE + } + updateTile() + } + } + + @SuppressLint("StartActivityAndCollapseDeprecated") + private fun openQuickAction() { + val intent = QuickAction.TOGGLE.quickIntent + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivityAndCollapse(intent.toPendingIntent) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + } +} diff --git a/android/app/src/main/kotlin/com/follow/clash/models/Package.kt b/android/app/src/main/kotlin/com/follow/clash/models/InstalledPackage.kt similarity index 85% rename from android/app/src/main/kotlin/com/follow/clash/models/Package.kt rename to android/app/src/main/kotlin/com/follow/clash/models/InstalledPackage.kt index 0a6d6c7cfc..f5018332cf 100644 --- a/android/app/src/main/kotlin/com/follow/clash/models/Package.kt +++ b/android/app/src/main/kotlin/com/follow/clash/models/InstalledPackage.kt @@ -1,6 +1,6 @@ package com.follow.clash.models -data class Package( +data class InstalledPackage( val packageName: String, val label: String, val system: Boolean, 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 new file mode 100644 index 0000000000..0868553710 --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/packages/PackageResolver.kt @@ -0,0 +1,174 @@ +package com.follow.clash.packages + +import android.Manifest +import android.content.pm.ApplicationInfo +import android.content.pm.ComponentInfo +import android.content.pm.PackageManager +import android.os.Build +import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile +import com.follow.clash.models.InstalledPackage +import java.io.File +import java.util.zip.ZipFile + +internal class PackageResolver( + private val packageManager: PackageManager, + private val appPackageName: String, +) { + val installedPackages: List by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + loadPackages() + } + + fun getChinaPackageNames(): List = installedPackages + .map { it.packageName } + .filter(::isChinaPackage) + + private fun loadPackages(): List { + val flags = PackageManager.GET_PERMISSIONS + val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getInstalledPackages( + PackageManager.PackageInfoFlags.of(flags.toLong()), + ) + } else { + @Suppress("DEPRECATION") + packageManager.getInstalledPackages(flags) + } + return packages.asSequence() + .filter { info -> + info.packageName != appPackageName && info.packageName != ANDROID_PACKAGE_NAME + } + .map { info -> + InstalledPackage( + packageName = info.packageName, + label = info.applicationInfo?.loadLabel(packageManager)?.toString() + ?: info.packageName, + system = info.applicationInfo?.let { applicationInfo -> + applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0 + } == true, + internet = info.requestedPermissions + ?.contains(Manifest.permission.INTERNET) == true, + lastUpdateTime = info.lastUpdateTime, + ) + }.toList() + } + + private fun isChinaPackage(packageName: String): Boolean { + if (SKIPPED_PREFIXES.any { packageName == it || packageName.startsWith("$it.") }) { + return false + } + if (packageName.matches(CHINA_PACKAGE_REGEX)) { + return true + } + + return runCatching { + val packageInfo = getPackageInfo(packageName) + packageInfo.componentNames().any { it.matches(CHINA_PACKAGE_REGEX) } || + packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true + }.getOrDefault(false) + } + + private fun getPackageInfo(packageName: String) = if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + ) { + packageManager.getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(PACKAGE_INFO_FLAGS.toLong()), + ) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageInfo(packageName, PACKAGE_INFO_FLAGS) + } + + private fun android.content.pm.PackageInfo.componentNames(): Sequence = sequence { + yieldAll(services.orEmpty().asSequence().map(ComponentInfo::name)) + yieldAll(activities.orEmpty().asSequence().map(ComponentInfo::name)) + yieldAll(receivers.orEmpty().asSequence().map(ComponentInfo::name)) + yieldAll(providers.orEmpty().asSequence().map(ComponentInfo::name)) + } + + private fun scanArchive(sourcePath: String): Boolean = ZipFile(File(sourcePath)).use { archive -> + if (archive.entries().asSequence().any { it.name.startsWith("firebase-") }) { + return false + } + archive.entries().asSequence() + .filter { entry -> + entry.name.startsWith("classes") && entry.name.endsWith(".dex") + }.any { entry -> + if (entry.size > MAX_DEX_SIZE_BYTES) { + return@any true + } + val dexFile = archive.getInputStream(entry).buffered().use { input -> + DexBackedDexFile.fromInputStream(null, input) + } + dexFile.classes.any { clazz -> + clazz.type + .removeSurrounding("L", ";") + .replace('/', '.') + .replace('$', '.') + .matches(CHINA_PACKAGE_REGEX) + } + } + } + + private companion object { + const val ANDROID_PACKAGE_NAME = "android" + const val MAX_DEX_SIZE_BYTES = 15_000_000L + + val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or + 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 e562051f8c..505d485ee9 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 @@ -1,11 +1,10 @@ package com.follow.clash.plugins import android.Manifest +import android.annotation.SuppressLint import android.app.Activity import android.app.ActivityManager import android.content.Intent -import android.content.pm.ApplicationInfo -import android.content.pm.ComponentInfo import android.content.pm.PackageManager import android.net.VpnService import android.os.Build @@ -18,17 +17,15 @@ import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri -import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile import com.follow.clash.R import com.follow.clash.common.Components import com.follow.clash.common.GlobalState import com.follow.clash.common.QuickAction import com.follow.clash.common.quickIntent import com.follow.clash.getPackageIconPath -import com.follow.clash.models.Package +import com.follow.clash.packages.PackageResolver import com.follow.clash.showToast import com.google.gson.Gson -import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -37,93 +34,39 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.Result import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.lang.ref.WeakReference -import java.util.zip.ZipFile class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware { - companion object { - const val VPN_PERMISSION_REQUEST_CODE = 1001 - const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002 - } - - private var activityRef: WeakReference? = null + private var activity: Activity? = null private lateinit var channel: MethodChannel private lateinit var scope: CoroutineScope - private var vpnPrepareCallback: (suspend () -> Unit)? = null - - private var requestNotificationCallback: (() -> Unit)? = null - - private val packages = mutableListOf() - - private val skipPrefixList = listOf( - "com.google", - "com.android.chrome", - "com.android.vending", - "com.microsoft", - "com.apple", - "com.zhiliaoapp.musically", // Banned by China - ) - - private val chinaAppPrefixList = 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" - ) - - private val chinaAppRegex by lazy { - ("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex() + private var vpnPrepareCallback: ((Boolean) -> Unit)? = null + + private var requestNotificationCallback: ((Boolean) -> Unit)? = null + + private var isRequestingNotificationPermission = false + + private val gson = Gson() + + private val packageResolver by lazy { + PackageResolver( + GlobalState.application.packageManager, + GlobalState.application.packageName, + ) } - private var isBlockNotification: Boolean = false + private var skipNotificationPermissionRequest = false override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { "moveTaskToBack" -> { - activityRef?.get()?.moveTaskToBack(true) + activity?.moveTaskToBack(true) result.success(true) } @@ -134,19 +77,24 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware } "initShortcuts" -> { - initShortcuts(call.arguments as String) - result.success(true) + val label = call.arguments as? String + if (label == null) { + result.error("INVALID_ARGUMENT", "Shortcut label must be a string", null) + } else { + initShortcuts(label) + result.success(true) + } } "getPackages" -> { - scope.launch { - result.success(getPackagesToJson()) + scope.launch(Dispatchers.IO) { + result.success(gson.toJson(packageResolver.installedPackages)) } } "getChinaPackageNames" -> { - scope.launch { - result.success(getChinaPackageNames()) + scope.launch(Dispatchers.IO) { + result.success(gson.toJson(packageResolver.getChinaPackageNames())) } } @@ -156,7 +104,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware "tip" -> { val message = call.argument("message") - tip(message) + GlobalState.application.showToast(message) result.success(true) } @@ -172,6 +120,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware result.success(openAppSettings()) } + "didCrashOnPreviousExecution" -> { + result.success(GlobalState.didCrashOnPreviousExecution()) + } + else -> { result.notImplemented() } @@ -197,32 +149,32 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware IconCompat.createWithResource( GlobalState.application, R.mipmap.ic_launcher_round, - ) + ), ) setIntent(QuickAction.TOGGLE.quickIntent) build() } ShortcutManagerCompat.setDynamicShortcuts( - GlobalState.application, listOf(shortcut) + GlobalState.application, + listOf(shortcut), ) } - private fun tip(message: String?) { - GlobalState.application.showToast(message) - } - private fun isBatteryOptimizationDisabled(): Boolean { val powerManager = getSystemService(GlobalState.application, PowerManager::class.java) return powerManager?.isIgnoringBatteryOptimizations(GlobalState.application.packageName) ?: false } + @SuppressLint("BatteryLife") private fun openBatteryOptimizationSettings(): Boolean { + // VPN continuity is the user-requested core function, so the direct exemption is intentional. + val activity = activity ?: return false return try { val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { data = "package:${GlobalState.application.packageName}".toUri() } - activityRef?.get()?.startActivity(intent) + activity.startActivity(intent) true } catch (_: Exception) { false @@ -230,11 +182,12 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware } private fun openAppSettings(): Boolean { + val activity = activity ?: return false return try { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = "package:${GlobalState.application.packageName}".toUri() } - activityRef?.get()?.startActivity(intent) + activity.startActivity(intent) true } catch (_: Exception) { false @@ -246,170 +199,82 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware val am = getSystemService(GlobalState.application, ActivityManager::class.java) val task = am?.appTasks?.firstOrNull { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - it.taskInfo.taskId == activityRef?.get()?.taskId + it.taskInfo.taskId == activity?.taskId } else { - it.taskInfo.id == activityRef?.get()?.taskId + it.taskInfo.id == activity?.taskId } } - - when (value) { - true -> task?.setExcludeFromRecents(value) - false -> task?.setExcludeFromRecents(value) - null -> task?.setExcludeFromRecents(false) - } - } - - - private fun getPackages(): List { - val packageManager = GlobalState.application.packageManager - if (packages.isNotEmpty()) return packages - packageManager?.getInstalledPackages(PackageManager.GET_META_DATA or PackageManager.GET_PERMISSIONS) - ?.filter { - it.packageName != GlobalState.application.packageName && it.packageName != "android" - }?.map { - Package( - packageName = it.packageName, - label = it.applicationInfo?.loadLabel(packageManager).toString(), - system = (it.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM)) != 0, - lastUpdateTime = it.lastUpdateTime, - internet = it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true - ) - }?.let { packages.addAll(it) } - return packages - } - - private suspend fun getPackagesToJson(): String { - return withContext(Dispatchers.Default) { - Gson().toJson(getPackages()) - } + task?.setExcludeFromRecents(value ?: false) } - private suspend fun getChinaPackageNames(): String { - return withContext(Dispatchers.Default) { - val packages: List = - getPackages().map { it.packageName }.filter { isChinaPackage(it) } - Gson().toJson(packages) - } - } - - fun requestNotificationsPermission(callBack: () -> Unit) { - requestNotificationCallback = callBack + fun requestNotificationPermission(callback: (Boolean) -> Unit) { + requestNotificationCallback?.invoke(false) + requestNotificationCallback = callback if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val permission = ContextCompat.checkSelfPermission( - GlobalState.application, Manifest.permission.POST_NOTIFICATIONS + GlobalState.application, + Manifest.permission.POST_NOTIFICATIONS, ) - if (permission == PackageManager.PERMISSION_GRANTED || isBlockNotification) { - invokeRequestNotificationCallback() + if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) { + invokeRequestNotificationCallback(true) return } - activityRef?.get()?.let { + if (isRequestingNotificationPermission) { + return + } + isRequestingNotificationPermission = true + activity?.let { ActivityCompat.requestPermissions( it, arrayOf(Manifest.permission.POST_NOTIFICATIONS), - NOTIFICATION_PERMISSION_REQUEST_CODE + NOTIFICATION_PERMISSION_REQUEST_CODE, ) - } + } ?: invokeRequestNotificationCallback(true) return - } else { - invokeRequestNotificationCallback() } - + invokeRequestNotificationCallback(true) } - fun invokeRequestNotificationCallback() { - requestNotificationCallback?.invoke() + private fun invokeRequestNotificationCallback(shouldStart: Boolean) { + isRequestingNotificationPermission = false + requestNotificationCallback?.invoke(shouldStart) requestNotificationCallback = null } - fun prepare(needPrepare: Boolean, callBack: (suspend () -> Unit)) { - vpnPrepareCallback = callBack + fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) { + invokeVpnPrepareCallback(false) + vpnPrepareCallback = callback if (!needPrepare) { - invokeVpnPrepareCallback() + invokeVpnPrepareCallback(true) return } val intent = VpnService.prepare(GlobalState.application) if (intent != null) { - activityRef?.get()?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE) + val activity = activity + if (activity == null) { + invokeVpnPrepareCallback(false) + } else { + @Suppress("DEPRECATION") + activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE) + } return } - invokeVpnPrepareCallback() + invokeVpnPrepareCallback(true) } - fun invokeVpnPrepareCallback() { - GlobalState.launch { - vpnPrepareCallback?.invoke() + fun cancelVpnPreparation(callback: (Boolean) -> Unit) { + if (vpnPrepareCallback === callback) { vpnPrepareCallback = null } } - - @Suppress("DEPRECATION") - private fun isChinaPackage(packageName: String): Boolean { - val packageManager = GlobalState.application.packageManager ?: return false - skipPrefixList.forEach { - if (packageName == it || packageName.startsWith("$it.")) return false - } - val packageManagerFlags = - PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - if (packageName.matches(chinaAppRegex)) { - return true - } - try { - val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getPackageInfo( - packageName, PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong()) - ) - } else { - packageManager.getPackageInfo( - packageName, packageManagerFlags - ) - } - mutableListOf().apply { - packageInfo.services?.let { addAll(it) } - packageInfo.activities?.let { addAll(it) } - packageInfo.receivers?.let { addAll(it) } - packageInfo.providers?.let { addAll(it) } - }.forEach { - if (it.name.matches(chinaAppRegex)) return true - } - packageInfo.applicationInfo?.publicSourceDir?.let { - ZipFile(File(it)).use { - for (packageEntry in it.entries()) { - if (packageEntry.name.startsWith("firebase-")) return false - } - for (packageEntry in it.entries()) { - if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith( - ".dex" - )) - ) { - continue - } - if (packageEntry.size > 15000000) { - return true - } - val input = it.getInputStream(packageEntry).buffered() - val dexFile = try { - DexBackedDexFile.fromInputStream(null, input) - } catch (e: Exception) { - return false - } - for (clazz in dexFile.classes) { - val clazzName = - clazz.type.substring(1, clazz.type.length - 1).replace("/", ".") - .replace("$", ".") - if (clazzName.matches(chinaAppRegex)) return true - } - } - } - } - } catch (_: Exception) { - return false - } - return false + private fun invokeVpnPrepareCallback(granted: Boolean) { + vpnPrepareCallback?.invoke(granted) + vpnPrepareCallback = null } override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - scope = CoroutineScope(Dispatchers.Default) + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) channel = MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app") channel.setMethodCallHandler(this) @@ -418,43 +283,58 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) scope.cancel() + invokeVpnPrepareCallback(false) + invokeRequestNotificationCallback(false) } override fun onAttachedToActivity(binding: ActivityPluginBinding) { - activityRef = WeakReference(binding.activity) + attachToActivity(binding) + } + + private fun attachToActivity(binding: ActivityPluginBinding) { + activity = binding.activity binding.addActivityResultListener(::onActivityResult) binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener) } override fun onDetachedFromActivityForConfigChanges() { - activityRef = null + activity = null } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - activityRef = WeakReference(binding.activity) + attachToActivity(binding) } override fun onDetachedFromActivity() { channel.invokeMethod("exit", null) - activityRef = null + activity = null + invokeVpnPrepareCallback(false) + invokeRequestNotificationCallback(false) } private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { - if (requestCode == VPN_PERMISSION_REQUEST_CODE) { - if (resultCode == FlutterActivity.RESULT_OK) { - invokeVpnPrepareCallback() - } + if (requestCode != VPN_PERMISSION_REQUEST_CODE) { + return false } + invokeVpnPrepareCallback(resultCode == Activity.RESULT_OK) return true } private fun onRequestPermissionsResultListener( - requestCode: Int, permissions: Array, grantResults: IntArray + requestCode: Int, + permissions: Array, + grantResults: IntArray, ): Boolean { - if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) { - isBlockNotification = true + if (requestCode != NOTIFICATION_PERMISSION_REQUEST_CODE) { + return false } - invokeRequestNotificationCallback() + skipNotificationPermissionRequest = true + invokeRequestNotificationCallback(true) return true } + + private companion object { + const val VPN_PERMISSION_REQUEST_CODE = 1001 + const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002 + } } 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 00d8bd3e8b..ccf4082ea7 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 @@ -1,10 +1,8 @@ package com.follow.clash.plugins -import com.follow.clash.RunState -import com.follow.clash.Service -import com.follow.clash.State +import com.follow.clash.ServiceController +import com.follow.clash.ServiceState import com.follow.clash.common.Components -import com.follow.clash.invokeMethodOnMainThread import com.follow.clash.models.SharedState import com.google.gson.Gson import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -13,127 +11,102 @@ import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, - CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { - private lateinit var flutterMethodChannel: MethodChannel +class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + private lateinit var channel: MethodChannel + private lateinit var scope: CoroutineScope + private val gson = Gson() - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - flutterMethodChannel = MethodChannel( - flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/service" - ) - flutterMethodChannel.setMethodCallHandler(this) + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + channel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/service") + channel.setMethodCallHandler(this) } - override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - flutterMethodChannel.setMethodCallHandler(null) + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + scope.cancel() + ServiceController.setEventListener(null) } - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) { - "init" -> { - handleInit(result) - } - - "shutdown" -> { - handleShutdown(result) - } - - "invokeAction" -> { - handleInvokeAction(call, result) - } - - "getRunTime" -> { - handleGetRunTime(result) - } - - "syncState" -> { - handleSyncState(call, result) - } - - "start" -> { - handleStart(result) - } - - "stop" -> { - handleStop(result) - } - - else -> { - result.notImplemented() - } - } - - private fun handleInvokeAction(call: MethodCall, result: MethodChannel.Result) { - launch { - val data = call.arguments()!! - Service.invokeAction(data) { - result.success(it) - } + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "init" -> initialize(result) + "shutdown" -> shutdown(result) + "invokeMethod" -> invokeMethod(call, result) + "getRunTime" -> getRunTime(result) + "syncState" -> syncState(call, result) + "start" -> start(result) + "stop" -> stop(result) + else -> result.notImplemented() } } - private fun handleShutdown(result: MethodChannel.Result) { - Service.unbind() - result.success(true) - } - - private fun handleStart(result: MethodChannel.Result) { - State.handleStartService() - result.success(true) + private fun initialize(result: MethodChannel.Result) { + ServiceController.setEventListener(::sendEvent) + .onSuccess { result.success("") } + .onFailure { error -> result.success(error.message.orEmpty()) } } - private fun handleStop(result: MethodChannel.Result) { - State.handleStopService() - result.success(true) + private fun shutdown(result: MethodChannel.Result) { + scope.launch { + ServiceController.unbind() + result.success(true) + } } - val semaphore = Semaphore(10) - - fun handleSendEvent(value: String?) { - launch(Dispatchers.Main) { - semaphore.withPermit { - flutterMethodChannel.invokeMethod("event", value) + private fun invokeMethod(call: MethodCall, result: MethodChannel.Result) { + val data = call.arguments as? String + if (data == null) { + result.error("INVALID_ARGUMENT", "Method call payload must be a string", null) + return + } + scope.launch { + ServiceController.invokeMethod(data) { response -> + result.success(response) + }.onFailure { error -> + result.error("CORE_ERROR", error.message, null) } } } - private fun onServiceDisconnected(message: String) { - State.runStateFlow.tryEmit(RunState.STOP) - flutterMethodChannel.invokeMethodOnMainThread("crash", message) + private fun getRunTime(result: MethodChannel.Result) { + scope.launch { + ServiceState.refresh() + result.success(ServiceState.runTimeMillis) + } } - private fun handleSyncState(call: MethodCall, result: MethodChannel.Result) { - val data = call.arguments()!! - State.sharedState = Gson().fromJson(data, SharedState::class.java) - launch { - State.syncState() + private fun syncState(call: MethodCall, result: MethodChannel.Result) { + val data = call.arguments as? String + val state = runCatching { + gson.fromJson(data, SharedState::class.java) + }.getOrNull() + if (state == null) { + result.success("Invalid shared state") + return + } + scope.launch { + ServiceState.syncSharedState(state) result.success("") } } + private fun start(result: MethodChannel.Result) { + ServiceState.requestStart() + result.success(true) + } - fun handleInit(result: MethodChannel.Result) { - Service.bind() - launch { - Service.setEventListener { - handleSendEvent(it) - }.onSuccess { - result.success("") - }.onFailure { - result.success(it.message) - } - - } - Service.onServiceDisconnected = ::onServiceDisconnected + private fun stop(result: MethodChannel.Result) { + ServiceState.requestStop() + result.success(true) } - private fun handleGetRunTime(result: MethodChannel.Result) { - launch { - State.handleSyncState() - result.success(State.runTime) + private fun sendEvent(value: String?) { + scope.launch(Dispatchers.Main) { + channel.invokeMethod("event", value) } } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/com/follow/clash/plugins/TilePlugin.kt b/android/app/src/main/kotlin/com/follow/clash/plugins/TilePlugin.kt index f7a5732b3b..744bf98b12 100644 --- a/android/app/src/main/kotlin/com/follow/clash/plugins/TilePlugin.kt +++ b/android/app/src/main/kotlin/com/follow/clash/plugins/TilePlugin.kt @@ -7,7 +7,6 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { - private lateinit var channel: MethodChannel override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { @@ -21,13 +20,14 @@ class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { } fun handleStart() { - channel.invokeMethodOnMainThread("start", null) + channel.invokeMethodOnMainThread("start") } fun handleStop() { - channel.invokeMethodOnMainThread("stop", null) + channel.invokeMethodOnMainThread("stop") } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {} -} \ No newline at end of file + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + result.notImplemented() + } +} diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml deleted file mode 100644 index 798f29681f..0000000000 --- a/android/app/src/main/res/xml/file_paths.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml deleted file mode 100644 index 9496fa2e5a..0000000000 --- a/android/app/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - localhost - 127.0.0.1 - - \ No newline at end of file diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f6981d5..0000000000 --- a/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 18fecbd96b..6ccdf128ad 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -14,9 +14,6 @@ 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 35e2c7c6ce..20d1afdd57 100644 --- a/android/common/build.gradle.kts +++ b/android/common/build.gradle.kts @@ -9,24 +9,13 @@ android { compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { - minSdk = 21 - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } + minSdk = libs.versions.minSdk.get().toInt() } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - } kotlin { diff --git a/android/common/consumer-rules.pro b/android/common/consumer-rules.pro deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/android/common/src/main/java/com/follow/clash/common/Components.kt b/android/common/src/main/java/com/follow/clash/common/Components.kt index 6d8dd5033f..cacc45e594 100644 --- a/android/common/src/main/java/com/follow/clash/common/Components.kt +++ b/android/common/src/main/java/com/follow/clash/common/Components.kt @@ -5,12 +5,12 @@ import android.content.ComponentName object Components { const val PACKAGE_NAME = "com.follow.clash" - val MAIN_ACTIVITY = + val mainActivity = ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.MainActivity") - val TEMP_ACTIVITY = - ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.TempActivity") + val quickActionActivity = + ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.QuickActionActivity") - val BROADCAST_RECEIVER = - ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.BroadcastReceiver") -} \ No newline at end of file + val serviceBroadcastReceiver = + ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.ServiceBroadcastReceiver") +} diff --git a/android/common/src/main/java/com/follow/clash/common/Enums.kt b/android/common/src/main/java/com/follow/clash/common/Enums.kt index 91310af04c..709e5bbfdf 100644 --- a/android/common/src/main/java/com/follow/clash/common/Enums.kt +++ b/android/common/src/main/java/com/follow/clash/common/Enums.kt @@ -2,7 +2,6 @@ package com.follow.clash.common import com.google.gson.annotations.SerializedName - enum class QuickAction { STOP, START, @@ -10,8 +9,8 @@ enum class QuickAction { } enum class BroadcastAction { - SERVICE_CREATED, - SERVICE_DESTROYED, + VPN_START_REQUESTED, + VPN_REVOKED, } enum class AccessControlMode { @@ -20,4 +19,4 @@ enum class AccessControlMode { @SerializedName("rejectSelected") REJECT_SELECTED, -} \ No newline at end of file +} diff --git a/android/common/src/main/java/com/follow/clash/common/Ext.kt b/android/common/src/main/java/com/follow/clash/common/Ext.kt index 71773d9cd0..9692ab345c 100644 --- a/android/common/src/main/java/com/follow/clash/common/Ext.kt +++ b/android/common/src/main/java/com/follow/clash/common/Ext.kt @@ -13,98 +13,60 @@ import android.content.Context import android.content.Context.RECEIVER_NOT_EXPORTED import android.content.Intent import android.content.IntentFilter -import android.content.ServiceConnection import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE import android.os.Build -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import android.os.RemoteException -import android.util.Log import androidx.core.content.getSystemService -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.retryWhen -import kotlinx.coroutines.withContext -import java.nio.charset.Charset import kotlin.reflect.KClass -//fun Context.startForegroundServiceCompat(intent: Intent?) { -// if (Build.VERSION.SDK_INT >= 26) { -// startForegroundService(intent) -// } else { -// startService(intent) -// } -//} - val KClass<*>.intent: Intent get() = Intent(GlobalState.application, this.java) -fun Service.startForegroundCompat(id: Int, notification: Notification) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground(id, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE) - } else { - startForeground(id, notification) - } -} - val ComponentName.intent: Intent get() = Intent().apply { - setComponent(this@intent) - setPackage(GlobalState.packageName) + component = this@intent } val QuickAction.action: String get() = "${GlobalState.application.packageName}.action.${this.name}" val QuickAction.quickIntent: Intent - get() = Components.TEMP_ACTIVITY.intent.apply { + get() = Components.quickActionActivity.intent.apply { action = this@quickIntent.action - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } val BroadcastAction.action: String get() = "${GlobalState.application.packageName}.intent.action.${this.name}" val Context.processName: String? - get() { - val pid = android.os.Process.myPid() - val activityManager = getSystemService() - activityManager?.runningAppProcesses?.find { it.pid == pid }?.let { - return it.processName - } - return null - } - -val BroadcastAction.quickIntent: Intent - get() = Components.BROADCAST_RECEIVER.intent.apply { - action = this@quickIntent.action - } + get() = getSystemService() + ?.runningAppProcesses + ?.firstOrNull { it.pid == android.os.Process.myPid() } + ?.processName fun BroadcastAction.sendBroadcast() { - val intent = Intent().apply { - action = this@sendBroadcast.action - Log.d("[sendBroadcast]", "$action") - setPackage(GlobalState.packageName) + val broadcastAction = action + val intent = Intent(broadcastAction).apply { + component = Components.serviceBroadcastReceiver } + GlobalState.log("Send broadcast: $broadcastAction") GlobalState.application.sendBroadcast( - intent, GlobalState.RECEIVE_BROADCASTS_PERMISSIONS + intent, + GlobalState.receiveBroadcastPermission, ) } - val Intent.toPendingIntent: PendingIntent get() = PendingIntent.getActivity( GlobalState.application, 0, this, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) - fun Service.startForeground(notification: Notification) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val manager = getSystemService(NotificationManager::class.java) @@ -112,13 +74,21 @@ fun Service.startForeground(notification: Notification) { if (channel == null) { channel = NotificationChannel( GlobalState.NOTIFICATION_CHANNEL, - "SERVICE_CHANNEL", - NotificationManager.IMPORTANCE_LOW + getString(R.string.service_channel_name), + NotificationManager.IMPORTANCE_LOW, ) manager?.createNotificationChannel(channel) } } - startForegroundCompat(GlobalState.NOTIFICATION_ID, notification) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + GlobalState.NOTIFICATION_ID, + notification, + FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + } else { + startForeground(GlobalState.NOTIFICATION_ID, notification) + } } @SuppressLint("UnspecifiedRegisterReceiverFlag") @@ -144,107 +114,3 @@ fun Context.receiveBroadcastFlow( registerReceiverCompat(receiver, filter) awaitClose { unregisterReceiver(receiver) } } - - -inline fun Context.bindServiceFlow( - intent: Intent, - flags: Int = Context.BIND_AUTO_CREATE, - maxRetries: Int = 10, - retryDelayMillis: Long = 200L -): Flow> = callbackFlow { - val connection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { - if (binder != null) { - try { - @Suppress("UNCHECKED_CAST") val casted = binder as? T - if (casted != null) { - trySend(Pair(casted, "")) - } else { - trySend(Pair(null, "Binder is not of type ${T::class.java}")) - } - } catch (e: RemoteException) { - trySend(Pair(null, "Failed to link to death: ${e.message}")) - } - } else { - trySend(Pair(null, "Binder empty")) - } - } - - override fun onServiceDisconnected(name: ComponentName?) { - trySend(Pair(null, "Service disconnected")) - } - } - - val success = withContext(Dispatchers.Main) { - bindService(intent, connection, flags) - } - - if (!success) { - throw IllegalStateException("bindService() failed, will retry") - } - - awaitClose { - Handler(Looper.getMainLooper()).post { - unbindService(connection) - trySend(Pair(null, "")) - } - } -}.retryWhen { cause, attempt -> - if (attempt < maxRetries && cause is Exception) { - delay(retryDelayMillis) - true - } else { - false - } -} - - -val Long.formatBytes: String - get() { - val units = arrayOf("B", "KB", "MB", "GB", "TB") - var size = this.toDouble() - var unitIndex = 0 - - while (size >= 1024 && unitIndex < units.size - 1) { - size /= 1024 - unitIndex++ - } - - return if (unitIndex == 0) { - "${size.toLong()}${units[unitIndex]}" - } else { - "%.1f${units[unitIndex]}".format(size) - } - } - -fun String.chunkedForAidl(charset: Charset = Charsets.UTF_8): List { - val allBytes = toByteArray(charset) - val total = allBytes.size - val maxBytes = when { - total <= 100 * 1024 -> total - total <= 1024 * 1024 -> 64 * 1024 - total <= 10 * 1024 * 1024 -> 128 * 1024 - else -> 256 * 1024 - } - - val result = mutableListOf() - var index = 0 - while (index < total) { - val end = minOf(index + maxBytes, total) - result.add(allBytes.copyOfRange(index, end)) - index = end - } - return result -} - - -fun > T.formatString(charset: Charset = Charsets.UTF_8): String { - val totalSize = this.sumOf { it.size } - val combined = ByteArray(totalSize) - var offset = 0 - forEach { byteArray -> - byteArray.copyInto(combined, offset) - offset += byteArray.size - } - return String(combined, charset) -} \ No newline at end of file diff --git a/android/common/src/main/java/com/follow/clash/common/GlobalState.kt b/android/common/src/main/java/com/follow/clash/common/GlobalState.kt index 0b7a2336e8..cc1f562700 100644 --- a/android/common/src/main/java/com/follow/clash/common/GlobalState.kt +++ b/android/common/src/main/java/com/follow/clash/common/GlobalState.kt @@ -1,47 +1,47 @@ package com.follow.clash.common - import android.app.Application import android.util.Log import com.google.firebase.FirebaseApp import com.google.firebase.crashlytics.FirebaseCrashlytics import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob -object GlobalState : CoroutineScope by CoroutineScope(Dispatchers.Default) { - +object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { const val NOTIFICATION_CHANNEL = "FlClash" - const val NOTIFICATION_ID = 1 val packageName: String get() = application.packageName - val RECEIVE_BROADCASTS_PERMISSIONS: String - get() = "${packageName}.permission.RECEIVE_BROADCASTS" - - - private var _application: Application? = null + val receiveBroadcastPermission: String + get() = "$packageName.permission.RECEIVE_BROADCASTS" val application: Application - get() = _application!! + get() = checkNotNull(appInstance) { "GlobalState is not initialized" } + @Volatile + private var appInstance: Application? = null - fun log(text: String) { - Log.d("[FlClash]", text) + fun init(application: Application) { + appInstance = application } - fun init(application: Application) { - _application = application + fun log(text: String) { + Log.d("FlClash", text) } fun setCrashlytics(enable: Boolean) { - _application?.let { - FirebaseApp.initializeApp(it) - FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable - if (enable) { - log("init crashlytics ${it.processName}") - } + FirebaseApp.initializeApp(application) + FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable + if (enable) { + log("Crashlytics enabled for ${application.processName}") } } -} \ No newline at end of file + + fun didCrashOnPreviousExecution(): Boolean { + FirebaseApp.initializeApp(application) + return FirebaseCrashlytics.getInstance().didCrashOnPreviousExecution() + } +} diff --git a/android/common/src/main/java/com/follow/clash/common/Service.kt b/android/common/src/main/java/com/follow/clash/common/Service.kt deleted file mode 100644 index 09433cb892..0000000000 --- a/android/common/src/main/java/com/follow/clash/common/Service.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.follow.clash.common - -import android.content.Intent -import android.os.IBinder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import java.util.concurrent.atomic.AtomicBoolean - -class ServiceDelegate( - private val intent: Intent, - private val onServiceDisconnected: ((String) -> Unit)? = null, - private val interfaceCreator: (IBinder) -> T, -) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { - - private val _bindingState = AtomicBoolean(false) - - private var _serviceState = MutableStateFlow?>(null) - - val serviceState: StateFlow?> = _serviceState - private var job: Job? = null - - private fun handleBind(data: Pair) { - data.first?.let { - _serviceState.value = Pair(interfaceCreator(it), data.second) - } ?: run { - _serviceState.value = Pair(null, data.second) - unbind() - onServiceDisconnected?.invoke(data.second) - _bindingState.set(false) - } - } - - fun bind() { - if (_bindingState.compareAndSet(false, true)) { - job?.cancel() - job = null - _serviceState.value = null - job = launch { - runCatching { - GlobalState.application.bindServiceFlow(intent) - .collect { handleBind(it) } - } - } - } - } - - suspend inline fun useService( - timeoutMillis: Long = 5000, crossinline block: suspend (T) -> R - ): Result { - return runCatching { - withTimeout(timeoutMillis) { - val state = serviceState.filterNotNull().first() - state.first?.let { - withContext(Dispatchers.Default) { - block(it) - } - } ?: throw Exception(state.second) - } - } - } - - fun unbind() { - if (_bindingState.compareAndSet(true, false)) { - job?.cancel() - job = null - _serviceState.value = null - } - } -} \ No newline at end of file diff --git a/android/common/src/main/java/com/follow/clash/common/Utils.kt b/android/common/src/main/java/com/follow/clash/common/Utils.kt deleted file mode 100644 index 2b22dd5e2d..0000000000 --- a/android/common/src/main/java/com/follow/clash/common/Utils.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.follow.clash.common - -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow - - -fun tickerFlow(delayMillis: Long, initialDelayMillis: Long = delayMillis): Flow = flow { - delay(initialDelayMillis) - while (true) { - emit(Unit) - delay(delayMillis) - } -} \ No newline at end of file diff --git a/android/common/src/main/res/values/strings.xml b/android/common/src/main/res/values/strings.xml index c31b20711e..1e795f3c45 100644 --- a/android/common/src/main/res/values/strings.xml +++ b/android/common/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ - FlClash + FlClash + FlClash Service diff --git a/android/core/build.gradle.kts b/android/core/build.gradle.kts index 915d5c9687..cac81a2ca2 100644 --- a/android/core/build.gradle.kts +++ b/android/core/build.gradle.kts @@ -13,13 +13,6 @@ android { minSdk = libs.versions.minSdk.get().toInt() } - - sourceSets { - getByName("main") { - jniLibs.srcDirs("src/main/jniLibs") - } - } - externalNativeBuild { cmake { path("src/main/cpp/CMakeLists.txt") @@ -32,14 +25,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - buildTypes { - release { - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } } kotlin { @@ -48,7 +33,6 @@ kotlin { } } - dependencies { implementation(libs.annotation.jvm) } diff --git a/android/core/src/main/cpp/core.cpp b/android/core/src/main/cpp/core.cpp index 878d20ccd9..816c75ffcc 100644 --- a/android/core/src/main/cpp/core.cpp +++ b/android/core/src/main/cpp/core.cpp @@ -34,9 +34,9 @@ Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns extern "C" JNIEXPORT void JNICALL -Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) { +Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) { const auto interface = new_global(cb); - invokeAction(interface, get_string(data)); + invokeMethod(interface, get_string(data)); } extern "C" @@ -54,14 +54,16 @@ extern "C" JNIEXPORT jstring JNICALL Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz, const jboolean only_statistics_proxy) { - return new_string(getTraffic(only_statistics_proxy)); + scoped_string traffic = getTraffic(only_statistics_proxy); + return new_string(traffic); } extern "C" JNIEXPORT jstring JNICALL Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz, const jboolean only_statistics_proxy) { - return new_string(getTotalTraffic(only_statistics_proxy)); + scoped_string traffic = getTotalTraffic(only_statistics_proxy); + return new_string(traffic); } extern "C" @@ -106,21 +108,31 @@ call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol, const char *target, const int uid) { ATTACH_JNI(); - const auto packageName = reinterpret_cast(env->CallObjectMethod( + const auto source_string = new_string(source); + const auto target_string = new_string(target); + const auto package_name = reinterpret_cast(env->CallObjectMethod( static_cast(tun_interface), m_tun_interface_resolve_process, protocol, - new_string(source), - new_string(target), + source_string, + target_string, uid)); - return get_string(packageName); + env->DeleteLocalRef(source_string); + env->DeleteLocalRef(target_string); + const auto result = get_string(package_name); + if (package_name != nullptr) { + env->DeleteLocalRef(package_name); + } + return result; } static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) { ATTACH_JNI(); + const auto value = new_string(data); env->CallVoidMethod(static_cast(invoke_interface), m_invoke_interface_result, - new_string(data)); + value); + env->DeleteLocalRef(value); } extern "C" @@ -166,7 +178,7 @@ Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) { extern "C" JNIEXPORT void JNICALL -Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) { +Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) { } extern "C" @@ -188,11 +200,13 @@ extern "C" JNIEXPORT jstring JNICALL Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz, const jboolean only_statistics_proxy) { + return env->NewStringUTF("{}"); } extern "C" JNIEXPORT jstring JNICALL Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz, const jboolean only_statistics_proxy) { + return env->NewStringUTF("{}"); } extern "C" @@ -205,4 +219,4 @@ JNIEXPORT void JNICALL Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string, jstring setup_params_string, jobject cb) { } -#endif \ No newline at end of file +#endif diff --git a/android/core/src/main/cpp/jni_helper.cpp b/android/core/src/main/cpp/jni_helper.cpp index e840fe3148..1858e35c8e 100644 --- a/android/core/src/main/cpp/jni_helper.cpp +++ b/android/core/src/main/cpp/jni_helper.cpp @@ -1,7 +1,6 @@ #include "jni_helper.h" #include -#include #include static JavaVM *global_vm; @@ -18,15 +17,15 @@ void initialize_jni(JavaVM *vm, JNIEnv *env) { m_get_bytes = find_method(c_string, "getBytes", "()[B"); } -JavaVM *global_java_vm() { - return global_vm; -} - char *jni_get_string(JNIEnv *env, jstring str) { + if (str == nullptr) { + return static_cast(calloc(1, 1)); + } const auto array = reinterpret_cast(env->CallObjectMethod(str, m_get_bytes)); const int length = env->GetArrayLength(array); const auto content = static_cast(malloc(length + 1)); env->GetByteArrayRegion(array, 0, length, reinterpret_cast(content)); + env->DeleteLocalRef(array); content[length] = 0; return content; } @@ -35,37 +34,28 @@ jstring jni_new_string(JNIEnv *env, const char *str) { const auto length = static_cast(strlen(str)); const auto array = env->NewByteArray(length); env->SetByteArrayRegion(array, 0, length, reinterpret_cast(str)); - return reinterpret_cast(env->NewObject(c_string, m_new_string, array)); -} - -int jni_catch_exception(JNIEnv *env) { - const int result = env->ExceptionCheck(); - if (result) { - env->ExceptionDescribe(); - env->ExceptionClear(); - } + const auto result = reinterpret_cast(env->NewObject(c_string, m_new_string, array)); + env->DeleteLocalRef(array); return result; } void jni_attach_thread(scoped_jni *jni) { - JavaVM *vm = global_java_vm(); - if (vm->GetEnv(reinterpret_cast(&jni->env), JNI_VERSION_1_6) == JNI_OK) { + if (global_vm->GetEnv(reinterpret_cast(&jni->env), JNI_VERSION_1_6) == JNI_OK) { jni->require_release = 0; return; } - if (vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) { + if (global_vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) { abort(); } jni->require_release = 1; } void jni_detach_thread(const scoped_jni *env) { - JavaVM *vm = global_java_vm(); if (env->require_release) { - vm->DetachCurrentThread(); + global_vm->DetachCurrentThread(); } } -void release_string(char **str) { - free(*str); -} \ No newline at end of file +void release_string(char **value) { + free(*value); +} diff --git a/android/core/src/main/cpp/jni_helper.h b/android/core/src/main/cpp/jni_helper.h index a408751999..9d89d9ff0f 100644 --- a/android/core/src/main/cpp/jni_helper.h +++ b/android/core/src/main/cpp/jni_helper.h @@ -13,13 +13,11 @@ extern jstring jni_new_string(JNIEnv *env, const char *str); extern char *jni_get_string(JNIEnv *env, jstring str); -extern int jni_catch_exception(JNIEnv *env); - extern void jni_attach_thread(scoped_jni *jni); extern void jni_detach_thread(const scoped_jni *env); -extern void release_string( char **str); +extern void release_string(char **value); #define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \ scoped_jni _jni{}; \ diff --git a/android/core/src/main/java/com/follow/clash/core/Core.kt b/android/core/src/main/java/com/follow/clash/core/Core.kt index bbc4e70f72..86aaff7042 100644 --- a/android/core/src/main/java/com/follow/clash/core/Core.kt +++ b/android/core/src/main/java/com/follow/clash/core/Core.kt @@ -2,9 +2,9 @@ package com.follow.clash.core import java.net.InetAddress import java.net.InetSocketAddress -import java.net.URL +import java.net.URI -data object Core { +object Core { private external fun startTun( fd: Int, cb: TunInterface, @@ -13,17 +13,17 @@ data object Core { dns: String, ) - external fun forceGC( - ) + external fun forceGC() external fun updateDNS( dns: String, ) private fun parseInetSocketAddress(address: String): InetSocketAddress { - val url = URL("https://$address") - - return InetSocketAddress(InetAddress.getByName(url.host), url.port) + val uri = URI("tcp://$address") + val host = requireNotNull(uri.host) { "Missing host in address: $address" } + require(uri.port >= 0) { "Missing port in address: $address" } + return InetSocketAddress(InetAddress.getByName(host), uri.port) } fun startTun( @@ -45,7 +45,7 @@ data object Core { protocol: Int, source: String, target: String, - uid: Int + uid: Int, ): String { return resolverProcess( protocol, @@ -57,7 +57,7 @@ data object Core { }, stack, address, - dns + dns, ) } @@ -65,16 +65,16 @@ data object Core { suspended: Boolean, ) - private external fun invokeAction( + private external fun invokeMethod( data: String, - cb: InvokeInterface + cb: InvokeInterface, ) - fun invokeAction( + fun invokeMethod( data: String, - cb: (result: String?) -> Unit + cb: (result: String?) -> Unit, ) { - invokeAction( + invokeMethod( data, object : InvokeInterface { override fun onResult(result: String?) { @@ -86,33 +86,33 @@ data object Core { private external fun setEventListener(cb: InvokeInterface?) - fun callSetEventListener( - cb: ((result: String?) -> Unit)? + fun updateEventListener( + callback: ((result: String?) -> Unit)?, ) { - when (cb != null) { - true -> setEventListener( + if (callback == null) { + setEventListener(null) + } else { + setEventListener( object : InvokeInterface { override fun onResult(result: String?) { - cb(result) + callback(result) } }, ) - - false -> setEventListener(null) } } fun quickSetup( initParamsString: String, setupParamsString: String, - cb: (result: String?) -> Unit, + callback: (result: String?) -> Unit, ) { quickSetup( initParamsString, setupParamsString, object : InvokeInterface { override fun onResult(result: String?) { - cb(result) + callback(result) } }, ) @@ -121,7 +121,7 @@ data object Core { private external fun quickSetup( initParamsString: String, setupParamsString: String, - cb: InvokeInterface + cb: InvokeInterface, ) external fun stopTun() @@ -133,4 +133,4 @@ data object Core { init { System.loadLibrary("core") } -} \ No newline at end of file +} diff --git a/android/core/src/main/java/com/follow/clash/core/TunInterface.kt b/android/core/src/main/java/com/follow/clash/core/TunInterface.kt index ed46d6c493..06c1a4bc0e 100644 --- a/android/core/src/main/java/com/follow/clash/core/TunInterface.kt +++ b/android/core/src/main/java/com/follow/clash/core/TunInterface.kt @@ -5,5 +5,6 @@ import androidx.annotation.Keep @Keep interface TunInterface { fun protect(fd: Int) + fun resolverProcess(protocol: Int, source: String, target: String, uid: Int): String -} \ No newline at end of file +} diff --git a/android/gradle.properties b/android/gradle.properties index f2122b21a2..cac97e80de 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,5 @@ org.gradle.jvmargs=-Xmx4G android.useAndroidX=true -android.enableJetifier=true # This builtInKotlin flag was added automatically by Flutter migrator android.builtInKotlin=false # This newDsl flag was added automatically by Flutter migrator diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index ed7237addb..c51cabf82c 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,5 +1,4 @@ [versions] -#agp = "8.10.1" firebaseBom = "34.15.0" minSdk = "23" targetSdk = "36" @@ -9,10 +8,7 @@ coreKtx = "1.17.0" annotationJvm = "1.9.1" coreSplashscreen = "1.0.1" gson = "2.13.1" -kotlin = "2.2.20" smaliDexlib2 = "3.0.9" -firebaseCrashlyticsKtx = "20.0.6" -firebaseCommonKtx = "22.1.0" [libraries] androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } @@ -23,5 +19,3 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "fir firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } smali-dexlib2 = { module = "com.android.tools.smali:smali-dexlib2", version.ref = "smaliDexlib2" } -firebase-crashlytics-ktx = { group = "com.google.firebase", name = "firebase-crashlytics-ktx", version.ref = "firebaseCrashlyticsKtx" } -firebase-common-ktx = { group = "com.google.firebase", name = "firebase-common-ktx", version.ref = "firebaseCommonKtx" } diff --git a/android/service/build.gradle.kts b/android/service/build.gradle.kts index ac715317a1..6fe7ad719a 100644 --- a/android/service/build.gradle.kts +++ b/android/service/build.gradle.kts @@ -6,29 +6,17 @@ plugins { android { namespace = "com.follow.clash.service" - compileSdk = 36 + compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } - buildFeatures { - aidl = true - } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - buildTypes { - release { - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } } kotlin { @@ -37,7 +25,6 @@ kotlin { } } - dependencies { implementation(project(":core")) implementation(project(":common")) diff --git a/android/service/src/main/AndroidManifest.xml b/android/service/src/main/AndroidManifest.xml index f633493802..dcd8699ca8 100644 --- a/android/service/src/main/AndroidManifest.xml +++ b/android/service/src/main/AndroidManifest.xml @@ -8,8 +8,7 @@ android:name=".VpnService" android:exported="false" android:foregroundServiceType="specialUse" - android:permission="android.permission.BIND_VPN_SERVICE" - android:process=":remote"> + android:permission="android.permission.BIND_VPN_SERVICE"> @@ -19,31 +18,23 @@ + android:foregroundServiceType="specialUse"> - - + android:permission="android.permission.MANAGE_DOCUMENTS"> - \ No newline at end of file + diff --git a/android/service/src/main/aidl/com/follow/clash/service/IAckInterface.aidl b/android/service/src/main/aidl/com/follow/clash/service/IAckInterface.aidl deleted file mode 100644 index 11efaa98d2..0000000000 --- a/android/service/src/main/aidl/com/follow/clash/service/IAckInterface.aidl +++ /dev/null @@ -1,8 +0,0 @@ -// IAckInterface.aidl -package com.follow.clash.service; - -import com.follow.clash.service.IAckInterface; - -interface IAckInterface { - oneway void onAck(); -} \ No newline at end of file diff --git a/android/service/src/main/aidl/com/follow/clash/service/ICallbackInterface.aidl b/android/service/src/main/aidl/com/follow/clash/service/ICallbackInterface.aidl deleted file mode 100644 index 5a8cf24e2e..0000000000 --- a/android/service/src/main/aidl/com/follow/clash/service/ICallbackInterface.aidl +++ /dev/null @@ -1,8 +0,0 @@ -// ICallbackInterface.aidl -package com.follow.clash.service; - -import com.follow.clash.service.IAckInterface; - -interface ICallbackInterface { - oneway void onResult(in byte[] data,in boolean isSuccess, in IAckInterface ack); -} \ No newline at end of file diff --git a/android/service/src/main/aidl/com/follow/clash/service/IEventInterface.aidl b/android/service/src/main/aidl/com/follow/clash/service/IEventInterface.aidl deleted file mode 100644 index 87c18974b2..0000000000 --- a/android/service/src/main/aidl/com/follow/clash/service/IEventInterface.aidl +++ /dev/null @@ -1,8 +0,0 @@ -// IEventInterface.aidl -package com.follow.clash.service; - -import com.follow.clash.service.IAckInterface; - -interface IEventInterface { - oneway void onEvent(in String id, in byte[] data,in boolean isSuccess, in IAckInterface ack); -} \ No newline at end of file diff --git a/android/service/src/main/aidl/com/follow/clash/service/IRemoteInterface.aidl b/android/service/src/main/aidl/com/follow/clash/service/IRemoteInterface.aidl deleted file mode 100644 index 4eac0b7dad..0000000000 --- a/android/service/src/main/aidl/com/follow/clash/service/IRemoteInterface.aidl +++ /dev/null @@ -1,20 +0,0 @@ -// IRemoteInterface.aidl -package com.follow.clash.service; - -import com.follow.clash.service.ICallbackInterface; -import com.follow.clash.service.IEventInterface; -import com.follow.clash.service.IResultInterface; -import com.follow.clash.service.IVoidInterface; -import com.follow.clash.service.models.VpnOptions; -import com.follow.clash.service.models.NotificationParams; - -interface IRemoteInterface { - void invokeAction(in String data, in ICallbackInterface callback); - void quickSetup(in String initParamsString, in String setupParamsString, in ICallbackInterface callback, in IVoidInterface onStarted); - void updateNotificationParams(in NotificationParams params); - void startService(in VpnOptions options, in long runTime, in IResultInterface result); - void stopService(in IResultInterface result); - void setEventListener(in IEventInterface event); - void setCrashlytics(in boolean enable); - long getRunTime(); -} \ No newline at end of file diff --git a/android/service/src/main/aidl/com/follow/clash/service/IResultInterface.aidl b/android/service/src/main/aidl/com/follow/clash/service/IResultInterface.aidl deleted file mode 100644 index 5f4f78b501..0000000000 --- a/android/service/src/main/aidl/com/follow/clash/service/IResultInterface.aidl +++ /dev/null @@ -1,6 +0,0 @@ -// IResultInterface.aidl -package com.follow.clash.service; - -interface IResultInterface { - oneway void onResult(in long runTime); -} \ No newline at end of file diff --git a/android/service/src/main/aidl/com/follow/clash/service/IVoidInterface.aidl b/android/service/src/main/aidl/com/follow/clash/service/IVoidInterface.aidl deleted file mode 100644 index 28db4c38e6..0000000000 --- a/android/service/src/main/aidl/com/follow/clash/service/IVoidInterface.aidl +++ /dev/null @@ -1,6 +0,0 @@ -// IVoidInterface.aidl -package com.follow.clash.service; - -interface IVoidInterface { - oneway void invoke(); -} \ No newline at end of file diff --git a/android/service/src/main/aidl/com/follow/clash/service/models/AccessControl.aidl b/android/service/src/main/aidl/com/follow/clash/service/models/AccessControl.aidl deleted file mode 100644 index ea75b66997..0000000000 --- a/android/service/src/main/aidl/com/follow/clash/service/models/AccessControl.aidl +++ /dev/null @@ -1,4 +0,0 @@ -//AccessControl.aidl -package com.follow.clash.service.models; - -parcelable AccessControl; \ No newline at end of file diff --git a/android/service/src/main/aidl/com/follow/clash/service/models/NotificationParams.aidl b/android/service/src/main/aidl/com/follow/clash/service/models/NotificationParams.aidl deleted file mode 100644 index 524221e7a5..0000000000 --- a/android/service/src/main/aidl/com/follow/clash/service/models/NotificationParams.aidl +++ /dev/null @@ -1,4 +0,0 @@ -//NotificationParams.aidl -package com.follow.clash.service.models; - -parcelable NotificationParams; \ No newline at end of file diff --git a/android/service/src/main/aidl/com/follow/clash/service/models/VpnOptions.aidl b/android/service/src/main/aidl/com/follow/clash/service/models/VpnOptions.aidl deleted file mode 100644 index c1d780d696..0000000000 --- a/android/service/src/main/aidl/com/follow/clash/service/models/VpnOptions.aidl +++ /dev/null @@ -1,6 +0,0 @@ -//VpnOptions.aidl -package com.follow.clash.service.models; - -import com.follow.clash.service.models.AccessControl; - -parcelable VpnOptions; \ No newline at end of file diff --git a/android/service/src/main/java/com/follow/clash/service/CommonService.kt b/android/service/src/main/java/com/follow/clash/service/CommonService.kt deleted file mode 100644 index 4f680d09da..0000000000 --- a/android/service/src/main/java/com/follow/clash/service/CommonService.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.follow.clash.service - -import android.app.Service -import android.content.Intent -import android.os.Binder -import android.os.IBinder -import com.follow.clash.core.Core -import com.follow.clash.service.modules.NetworkObserveModule -import com.follow.clash.service.modules.NotificationModule -import com.follow.clash.service.modules.SuspendModule -import com.follow.clash.service.modules.moduleLoader -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers - -class CommonService : Service(), IBaseService, - CoroutineScope by CoroutineScope(Dispatchers.Default) { - - private val self: CommonService - get() = this - - private val loader = moduleLoader { - install(NetworkObserveModule(self)) - install(NotificationModule(self)) - install(SuspendModule(self)) - } - - override fun onCreate() { - super.onCreate() - handleCreate() - } - - override fun onDestroy() { - handleDestroy() - super.onDestroy() - } - - override fun onLowMemory() { - Core.forceGC() - super.onLowMemory() - } - - private val binder = LocalBinder() - - inner class LocalBinder : Binder() { - fun getService(): CommonService = this@CommonService - } - - override fun onBind(intent: Intent): IBinder { - return binder - } - - override fun start() { - try { - loader.load() - } catch (_: Exception) { - stop() - } - } - - override fun stop() { - loader.cancel() - stopSelf() - } -} \ No newline at end of file diff --git a/android/service/src/main/java/com/follow/clash/service/FilesProvider.kt b/android/service/src/main/java/com/follow/clash/service/FilesProvider.kt index 26601feae8..c3d974bbd7 100644 --- a/android/service/src/main/java/com/follow/clash/service/FilesProvider.kt +++ b/android/service/src/main/java/com/follow/clash/service/FilesProvider.kt @@ -6,60 +6,34 @@ import android.os.CancellationSignal import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.DocumentsProvider +import com.follow.clash.common.R as CommonR import java.io.File import java.io.FileNotFoundException +import java.io.IOException class FilesProvider : DocumentsProvider() { + override fun onCreate() = true - companion object { - private const val DEFAULT_ROOT_ID = "0" - - private val DEFAULT_DOCUMENT_COLUMNS = arrayOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentsContract.Document.COLUMN_DISPLAY_NAME, - DocumentsContract.Document.COLUMN_MIME_TYPE, - DocumentsContract.Document.COLUMN_FLAGS, - DocumentsContract.Document.COLUMN_SIZE, - ) - private val DEFAULT_ROOT_COLUMNS = arrayOf( - DocumentsContract.Root.COLUMN_ROOT_ID, - DocumentsContract.Root.COLUMN_FLAGS, - DocumentsContract.Root.COLUMN_ICON, - DocumentsContract.Root.COLUMN_TITLE, - DocumentsContract.Root.COLUMN_SUMMARY, - DocumentsContract.Root.COLUMN_DOCUMENT_ID - ) - } - - override fun onCreate(): Boolean { - return true - } - - override fun queryRoots(projection: Array?): Cursor { - return MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply { - newRow().apply { - add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID) - add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY) - add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service) - add(DocumentsContract.Root.COLUMN_TITLE, "FlClash") - add(DocumentsContract.Root.COLUMN_SUMMARY, "Data") - add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, "/") - } + override fun queryRoots(projection: Array?): Cursor = + MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply { + newRow() + .add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID) + .add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY) + .add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service) + .add( + DocumentsContract.Root.COLUMN_TITLE, + context?.getString(CommonR.string.app_name).orEmpty(), + ) + .add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID) } - } - override fun queryChildDocuments( parentDocumentId: String, projection: Array?, - sortOrder: String? + sortOrder: String?, ): Cursor { - val result = MatrixCursor(resolveDocumentProjection(projection)) - val parentFile = if (parentDocumentId == "/") { - context?.filesDir - } else { - File(parentDocumentId) - } ?: throw FileNotFoundException("Parent directory not found") + val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_COLUMNS) + val parentFile = resolveFile(parentDocumentId) parentFile.listFiles()?.forEach { file -> includeFile(result, file) } @@ -67,20 +41,18 @@ class FilesProvider : DocumentsProvider() { } override fun queryDocument(documentId: String, projection: Array?): Cursor { - val result = MatrixCursor(resolveDocumentProjection(projection)) - val file = File(documentId) - includeFile(result, file) + val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_COLUMNS) + includeFile(result, resolveFile(documentId)) return result } override fun openDocument( documentId: String, mode: String, - signal: CancellationSignal? + signal: CancellationSignal?, ): ParcelFileDescriptor { - val file = File(documentId) val accessMode = ParcelFileDescriptor.parseMode(mode) - return ParcelFileDescriptor.open(file, accessMode) + return ParcelFileDescriptor.open(resolveFile(documentId), accessMode) } private fun includeFile(result: MatrixCursor, file: File) { @@ -88,23 +60,54 @@ class FilesProvider : DocumentsProvider() { add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, file.absolutePath) add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name) add(DocumentsContract.Document.COLUMN_SIZE, file.length()) - add( - DocumentsContract.Document.COLUMN_FLAGS, - DocumentsContract.Document.FLAG_SUPPORTS_WRITE or DocumentsContract.Document.FLAG_SUPPORTS_DELETE - ) - add(DocumentsContract.Document.COLUMN_MIME_TYPE, getDocumentType(file)) + val flags = if (file.isFile) { + DocumentsContract.Document.FLAG_SUPPORTS_WRITE + } else { + 0 + } + val mimeType = if (file.isDirectory) { + DocumentsContract.Document.MIME_TYPE_DIR + } else { + "application/octet-stream" + } + add(DocumentsContract.Document.COLUMN_FLAGS, flags) + add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType) } } - private fun getDocumentType(file: File): String { - return if (file.isDirectory) { - DocumentsContract.Document.MIME_TYPE_DIR - } else { - "application/octet-stream" + private fun resolveFile(documentId: String): File { + val root = context?.filesDir?.canonicalFile + ?: throw FileNotFoundException("App files directory is unavailable") + val file = try { + if (documentId == ROOT_DOCUMENT_ID) root else File(documentId).canonicalFile + } catch (error: IOException) { + throw FileNotFoundException(error.message).apply { initCause(error) } + } + val isInsideRoot = file == root || file.path.startsWith("${root.path}${File.separator}") + if (!isInsideRoot) { + throw FileNotFoundException("Document is outside the app files directory") } + return file } - private fun resolveDocumentProjection(projection: Array?): Array { - return projection ?: DEFAULT_DOCUMENT_COLUMNS + private companion object { + const val DEFAULT_ROOT_ID = "0" + const val ROOT_DOCUMENT_ID = "/" + + val DEFAULT_DOCUMENT_COLUMNS = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_SIZE, + ) + val DEFAULT_ROOT_COLUMNS = arrayOf( + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_ICON, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_SUMMARY, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + ) } -} \ No newline at end of file +} diff --git a/android/service/src/main/java/com/follow/clash/service/IBaseService.kt b/android/service/src/main/java/com/follow/clash/service/IBaseService.kt deleted file mode 100644 index 7206ebf192..0000000000 --- a/android/service/src/main/java/com/follow/clash/service/IBaseService.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.follow.clash.service - -import com.follow.clash.common.BroadcastAction -import com.follow.clash.common.GlobalState -import com.follow.clash.common.sendBroadcast - -interface IBaseService { - fun handleCreate() { - GlobalState.log("Service create") - BroadcastAction.SERVICE_CREATED.sendBroadcast() - } - - fun handleDestroy() { - GlobalState.log("Service destroy") - BroadcastAction.SERVICE_DESTROYED.sendBroadcast() - } - - fun start() - - fun stop() -} \ No newline at end of file diff --git a/android/service/src/main/java/com/follow/clash/service/ManagedService.kt b/android/service/src/main/java/com/follow/clash/service/ManagedService.kt new file mode 100644 index 0000000000..32a5a0e204 --- /dev/null +++ b/android/service/src/main/java/com/follow/clash/service/ManagedService.kt @@ -0,0 +1,22 @@ +package com.follow.clash.service + +import android.app.Service +import com.follow.clash.common.BroadcastAction +import com.follow.clash.common.GlobalState +import com.follow.clash.common.sendBroadcast + +interface ManagedService { + fun start() + + fun stop() +} + +internal fun Service.notifyVpnStartRequested() { + GlobalState.log("VPN start requested") + BroadcastAction.VPN_START_REQUESTED.sendBroadcast() +} + +internal fun Service.notifyVpnRevoked() { + GlobalState.log("VPN permission revoked") + BroadcastAction.VPN_REVOKED.sendBroadcast() +} diff --git a/android/service/src/main/java/com/follow/clash/service/ProxyService.kt b/android/service/src/main/java/com/follow/clash/service/ProxyService.kt new file mode 100644 index 0000000000..eba9f28d6d --- /dev/null +++ b/android/service/src/main/java/com/follow/clash/service/ProxyService.kt @@ -0,0 +1,52 @@ +package com.follow.clash.service + +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import com.follow.clash.core.Core +import com.follow.clash.service.modules.ServiceModules + +class ProxyService : Service(), ManagedService { + private val modules = ServiceModules(this) + private val binder = LocalBinder() + + override fun onDestroy() { + try { + cleanup() + } finally { + super.onDestroy() + } + } + + override fun onLowMemory() { + Core.forceGC() + super.onLowMemory() + } + + inner class LocalBinder : Binder() { + val service: ProxyService + get() = this@ProxyService + } + + override fun onBind(intent: Intent): IBinder = binder + + override fun start() { + try { + modules.start() + } catch (error: Exception) { + stop() + throw error + } + } + + override fun stop() { + try { + cleanup() + } finally { + stopSelf() + } + } + + private fun cleanup() = modules.stop() +} diff --git a/android/service/src/main/java/com/follow/clash/service/RemoteService.kt b/android/service/src/main/java/com/follow/clash/service/RemoteService.kt deleted file mode 100644 index 54e608a713..0000000000 --- a/android/service/src/main/java/com/follow/clash/service/RemoteService.kt +++ /dev/null @@ -1,196 +0,0 @@ -package com.follow.clash.service - -import android.app.Service -import android.content.Intent -import android.os.IBinder -import com.follow.clash.common.GlobalState -import com.follow.clash.common.ServiceDelegate -import com.follow.clash.common.chunkedForAidl -import com.follow.clash.common.intent -import com.follow.clash.core.Core -import com.follow.clash.service.State.delegate -import com.follow.clash.service.State.intent -import com.follow.clash.service.State.runLock -import com.follow.clash.service.models.NotificationParams -import com.follow.clash.service.models.VpnOptions -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.withLock -import java.util.UUID -import kotlin.coroutines.resume - -class RemoteService : Service(), - CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { - private fun handleStopService(result: IResultInterface) { - launch { - runLock.withLock { - delegate?.useService { service -> - service.stop() - delegate?.unbind() - } - State.runTime = 0 - result.onResult(0) - } - } - } - - private fun handleServiceDisconnected(message: String) { - GlobalState.log("Background service disconnected: $message") - intent = null - delegate = null - } - - private fun handleStartService(runTime: Long, result: IResultInterface) { - launch { - runLock.withLock { - val nextIntent = when (State.options?.enable == true) { - true -> VpnService::class.intent - false -> CommonService::class.intent - } - if (intent != nextIntent) { - delegate?.unbind() - delegate = ServiceDelegate(nextIntent, ::handleServiceDisconnected) { binder -> - when (binder) { - is VpnService.LocalBinder -> binder.getService() - is CommonService.LocalBinder -> binder.getService() - else -> throw IllegalArgumentException("Invalid binder type") - } - } - intent = nextIntent - delegate?.bind() - } - delegate?.useService { service -> - service.start() - } - State.runTime = when (runTime != 0L) { - true -> runTime - false -> System.currentTimeMillis() - } - result.onResult(State.runTime) - } - } - } - - private val binder = object : IRemoteInterface.Stub() { - override fun invokeAction(data: String, callback: ICallbackInterface) { - Core.invokeAction(data) { - launch { - runCatching { - val chunks = it?.chunkedForAidl() ?: listOf() - for ((index, chunk) in chunks.withIndex()) { - suspendCancellableCoroutine { cont -> - callback.onResult( - chunk, - index == chunks.lastIndex, - object : IAckInterface.Stub() { - override fun onAck() { - cont.resume(Unit) - } - }, - ) - } - } - } - } - } - } - - override fun quickSetup( - initParamsString: String, - setupParamsString: String, - callback: ICallbackInterface, - onStarted: IVoidInterface - ) { - Core.quickSetup(initParamsString, setupParamsString) { - launch { - runCatching { - val chunks = it?.chunkedForAidl() ?: listOf() - for ((index, chunk) in chunks.withIndex()) { - suspendCancellableCoroutine { cont -> - callback.onResult( - chunk, - index == chunks.lastIndex, - object : IAckInterface.Stub() { - override fun onAck() { - cont.resume(Unit) - } - }, - ) - } - } - } - } - } - onStarted() - } - - override fun updateNotificationParams(params: NotificationParams?) { - State.notificationParamsFlow.tryEmit(params) - } - - - override fun startService( - options: VpnOptions, - runtime: Long, - result: IResultInterface, - ) { - GlobalState.log("remote startService") - State.options = options - handleStartService(runtime, result) - } - - override fun stopService(result: IResultInterface) { - handleStopService(result) - } - - override fun setEventListener(eventListener: IEventInterface?) { - GlobalState.log("RemoveEventListener ${eventListener == null}") - when (eventListener != null) { - true -> Core.callSetEventListener { - launch { - runCatching { - val id = UUID.randomUUID().toString() - val chunks = it?.chunkedForAidl() ?: listOf() - for ((index, chunk) in chunks.withIndex()) { - suspendCancellableCoroutine { cont -> - eventListener.onEvent( - id, - chunk, - index == chunks.lastIndex, - object : IAckInterface.Stub() { - override fun onAck() { - cont.resume(Unit) - } - }, - ) - } - } - } - } - } - - false -> Core.callSetEventListener(null) - } - } - - override fun setCrashlytics(enable: Boolean) { - GlobalState.setCrashlytics(enable) - } - - override fun getRunTime(): Long { - return State.runTime - } - } - - override fun onBind(intent: Intent?): IBinder { - return binder - } - - override fun onDestroy() { - GlobalState.log("Remote service destroy") - super.onDestroy() - } -} \ No newline at end of file diff --git a/android/service/src/main/java/com/follow/clash/service/ServiceConfig.kt b/android/service/src/main/java/com/follow/clash/service/ServiceConfig.kt new file mode 100644 index 0000000000..51095f0d8d --- /dev/null +++ b/android/service/src/main/java/com/follow/clash/service/ServiceConfig.kt @@ -0,0 +1,25 @@ +package com.follow.clash.service + +import com.follow.clash.service.models.NotificationParams +import com.follow.clash.service.models.VpnOptions +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +object ServiceConfig { + @Volatile + private var currentVpnOptions: VpnOptions? = null + private val mutableNotificationParams = MutableStateFlow(NotificationParams()) + + val vpnOptions: VpnOptions? + get() = currentVpnOptions + + val notificationParams = mutableNotificationParams.asStateFlow() + + fun updateVpnOptions(options: VpnOptions) { + currentVpnOptions = options + } + + fun updateNotificationParams(params: NotificationParams) { + mutableNotificationParams.value = params + } +} diff --git a/android/service/src/main/java/com/follow/clash/service/State.kt b/android/service/src/main/java/com/follow/clash/service/State.kt deleted file mode 100644 index 670626fe20..0000000000 --- a/android/service/src/main/java/com/follow/clash/service/State.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.follow.clash.service - -import android.content.Intent -import com.follow.clash.common.ServiceDelegate -import com.follow.clash.service.models.NotificationParams -import com.follow.clash.service.models.VpnOptions -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.sync.Mutex - -object State { - var options: VpnOptions? = null - var notificationParamsFlow: MutableStateFlow = MutableStateFlow( - NotificationParams() - ) - - val runLock = Mutex() - var runTime: Long = 0L - - var delegate: ServiceDelegate? = null - - var intent: Intent? = null -} \ No newline at end of file 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 f576702761..c88f6d0e76 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 @@ -6,52 +6,40 @@ import android.net.ProxyInfo import android.os.Binder import android.os.Build import android.os.IBinder -import android.os.Parcel -import android.os.RemoteException import android.util.Log import androidx.core.content.getSystemService import com.follow.clash.common.AccessControlMode import com.follow.clash.common.GlobalState +import com.follow.clash.common.R as CommonR import com.follow.clash.core.Core +import com.follow.clash.service.models.CIDR import com.follow.clash.service.models.VpnOptions import com.follow.clash.service.models.getIpv4RouteAddress import com.follow.clash.service.models.getIpv6RouteAddress import com.follow.clash.service.models.toCIDR -import com.follow.clash.service.modules.NetworkObserveModule -import com.follow.clash.service.modules.NotificationModule -import com.follow.clash.service.modules.SuspendModule -import com.follow.clash.service.modules.moduleLoader -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import com.follow.clash.service.modules.ServiceModules import java.net.InetSocketAddress +import java.util.concurrent.ConcurrentHashMap import android.net.VpnService as SystemVpnService -class VpnService : SystemVpnService(), IBaseService, - CoroutineScope by CoroutineScope(Dispatchers.Default) { - - private val self: VpnService - get() = this - - private val loader = moduleLoader { - install(NetworkObserveModule(self)) - install(NotificationModule(self)) - install(SuspendModule(self)) - } - - override fun onCreate() { - super.onCreate() - handleCreate() - } +class VpnService : SystemVpnService(), ManagedService { + private val modules = ServiceModules(this) + private val binder = LocalBinder() + private val tunLock = Any() + private var tunRunning = false override fun onDestroy() { - handleDestroy() - super.onDestroy() + try { + cleanup() + } finally { + super.onDestroy() + } } private val connectivity by lazy { getSystemService() } - private val uidPageNameMap = mutableMapOf() + private val uidPackageNameMap = ConcurrentHashMap() private fun resolverProcess( protocol: Int, @@ -67,13 +55,15 @@ class VpnService : SystemVpnService(), IBaseService, if (nextUid == -1) { return "" } - if (!uidPageNameMap.containsKey(nextUid)) { - uidPageNameMap[nextUid] = this.packageManager?.getPackagesForUid(nextUid)?.first() ?: "" + return uidPackageNameMap.getOrPut(nextUid) { + packageManager + .getPackagesForUid(nextUid) + ?.firstOrNull() + .orEmpty() } - return uidPageNameMap[nextUid] ?: "" } - val VpnOptions.address + private val VpnOptions.tunAddress get(): String = buildString { append(IPV4_ADDRESS) if (ipv6) { @@ -82,7 +72,7 @@ class VpnService : SystemVpnService(), IBaseService, } } - val VpnOptions.dns + private val VpnOptions.tunDns get(): String { if (dnsHijacking) { return NET_ANY @@ -96,158 +86,180 @@ class VpnService : SystemVpnService(), IBaseService, } } - override fun onLowMemory() { Core.forceGC() super.onLowMemory() } - private val binder = LocalBinder() - inner class LocalBinder : Binder() { - fun getService(): VpnService = this@VpnService + val service: VpnService + get() = this@VpnService + } - override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { - try { - val isSuccess = super.onTransact(code, data, reply, flags) - if (!isSuccess) { - GlobalState.log("VpnService disconnected") - handleDestroy() - } - return isSuccess - } catch (e: RemoteException) { - GlobalState.log("VpnService onTransact $e") - return false - } + override fun onBind(intent: Intent): IBinder? = + if (intent.action == SystemVpnService.SERVICE_INTERFACE) { + super.onBind(intent) + } else { + binder } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Android starts always-on VPN through this callback instead of FlClash's bound-service + // path. Notify the app layer so it can restore Core and fully initialize the VPN service. + notifyVpnStartRequested() + return super.onStartCommand(intent, flags, startId) } - override fun onBind(intent: Intent): IBinder { - return binder + override fun onRevoke() { + stop() + notifyVpnRevoked() } private fun handleStart(options: VpnOptions) { val fd = with(Builder()) { - val cidr = IPV4_ADDRESS.toCIDR() - addAddress(cidr.address, cidr.prefixLength) - Log.d( - "addAddress", "address: ${cidr.address} prefixLength:${cidr.prefixLength}" - ) - val routeAddress = options.getIpv4RouteAddress() - if (routeAddress.isNotEmpty()) { - try { - routeAddress.forEach { i -> - Log.d( - "addRoute4", "address: ${i.address} prefixLength:${i.prefixLength}" - ) - addRoute(i.address, i.prefixLength) - } - } catch (_: Exception) { - addRoute(NET_ANY, 0) - } - } else { - addRoute(NET_ANY, 0) - } - if (options.ipv6) { - try { - val cidr = IPV6_ADDRESS.toCIDR() - Log.d( - "addAddress6", "address: ${cidr.address} prefixLength:${cidr.prefixLength}" - ) - addAddress(cidr.address, cidr.prefixLength) - } catch (_: Exception) { - Log.d( - "addAddress6", "IPv6 is not supported." - ) - } - - try { - val routeAddress = options.getIpv6RouteAddress() - if (routeAddress.isNotEmpty()) { - try { - routeAddress.forEach { i -> - Log.d( - "addRoute6", - "address: ${i.address} prefixLength:${i.prefixLength}" - ) - addRoute(i.address, i.prefixLength) - } - } catch (_: Exception) { - addRoute("::", 0) - } - } else { - addRoute(NET_ANY6, 0) - } - } catch (_: Exception) { - addRoute(NET_ANY6, 0) - } - } - addDnsServer(DNS) - if (options.ipv6) { - addDnsServer(DNS6) - } - setMtu(9000) - options.accessControlProps.let { accessControl -> - if (accessControl.enable) { - when (accessControl.mode) { - AccessControlMode.ACCEPT_SELECTED -> { - (accessControl.acceptList + packageName).forEach { - addAllowedApplication(it) - } - } - - AccessControlMode.REJECT_SELECTED -> { - (accessControl.rejectList - packageName).forEach { - addDisallowedApplication(it) - } - } - } - } - } - setSession("FlClash") + addAddressAndRoutes(options) + addDnsServers(options) + setMtu(MTU) + configureAccessControl(options) + setSession(getString(CommonR.string.app_name)) setBlocking(false) - if (Build.VERSION.SDK_INT >= 29) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { setMetered(false) } if (options.allowBypass) { allowBypass() } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && options.systemProxy) { - GlobalState.log("Open http proxy") + GlobalState.log("Enable VPN HTTP proxy") setHttpProxy( ProxyInfo.buildDirectProxy( - "127.0.0.1", options.port, options.bypassDomain - ) + LOCAL_HOST, + options.port, + options.bypassDomain, + ), ) } establish()?.detachFd() - ?: throw NullPointerException("Establish VPN rejected by system") + ?: error("VPN establishment was rejected by the system") + } + synchronized(tunLock) { + tunRunning = true + try { + Core.startTun( + fd = fd, + protect = this::protect, + resolverProcess = this::resolverProcess, + stack = options.stack, + address = options.tunAddress, + dns = options.tunDns, + ) + } catch (error: Exception) { + stopTunLocked() + throw error + } } - Core.startTun( - fd, - protect = this::protect, - resolverProcess = this::resolverProcess, - options.stack, - options.address, - options.dns + } + + private fun Builder.addAddressAndRoutes(options: VpnOptions) { + val ipv4Address = IPV4_ADDRESS.toCIDR() + addAddress(ipv4Address.address, ipv4Address.prefixLength) + addRoutes( + routes = options::getIpv4RouteAddress, + fallbackAddress = NET_ANY, + logTag = "addRoute4", ) + + if (options.ipv6) { + try { + val ipv6Address = IPV6_ADDRESS.toCIDR() + addAddress(ipv6Address.address, ipv6Address.prefixLength) + } catch (_: Exception) { + GlobalState.log("IPv6 VPN address is not supported") + } + addRoutes( + routes = options::getIpv6RouteAddress, + fallbackAddress = NET_ANY6, + logTag = "addRoute6", + ) + } } - override fun start() { + private fun Builder.addRoutes( + routes: () -> List, + fallbackAddress: String, + logTag: String, + ) { + val routeList = runCatching(routes).getOrDefault(emptyList()) + if (routeList.isEmpty()) { + addRoute(fallbackAddress, 0) + return + } try { - loader.load() - State.options?.let { - handleStart(it) + routeList.forEach { route -> + Log.d(logTag, "address: ${route.address} prefixLength: ${route.prefixLength}") + addRoute(route.address, route.prefixLength) } } catch (_: Exception) { + addRoute(fallbackAddress, 0) + } + } + + private fun Builder.addDnsServers(options: VpnOptions) { + addDnsServer(DNS) + if (options.ipv6) { + addDnsServer(DNS6) + } + } + + private fun Builder.configureAccessControl(options: VpnOptions) { + val accessControl = options.accessControlProps + if (!accessControl.enable) return + when (accessControl.mode) { + AccessControlMode.ACCEPT_SELECTED -> { + (accessControl.acceptList + packageName).forEach(::addAllowedApplication) + } + + AccessControlMode.REJECT_SELECTED -> { + (accessControl.rejectList - packageName).forEach(::addDisallowedApplication) + } + } + } + + override fun start() { + try { + modules.start() + handleStart(requireNotNull(ServiceConfig.vpnOptions) { "VPN options are missing" }) + } catch (error: Exception) { stop() + throw error } } override fun stop() { - loader.cancel() - Core.stopTun() - stopSelf() + try { + cleanup() + } finally { + stopSelf() + } + } + + private fun cleanup() { + try { + modules.stop() + } finally { + stopTun() + } + } + + private fun stopTun() = synchronized(tunLock) { + stopTunLocked() + } + + private fun stopTunLocked() { + if (tunRunning) { + Core.stopTun() + tunRunning = false + } } companion object { @@ -257,5 +269,7 @@ class VpnService : SystemVpnService(), IBaseService, private const val DNS6 = "fdfe:dcba:9876::2" private const val NET_ANY = "0.0.0.0" private const val NET_ANY6 = "::" + private const val LOCAL_HOST = "127.0.0.1" + private const val MTU = 9000 } -} \ No newline at end of file +} diff --git a/android/service/src/main/java/com/follow/clash/service/models/NotificationParams.kt b/android/service/src/main/java/com/follow/clash/service/models/NotificationParams.kt index dc18eeb4ad..c72ca69872 100644 --- a/android/service/src/main/java/com/follow/clash/service/models/NotificationParams.kt +++ b/android/service/src/main/java/com/follow/clash/service/models/NotificationParams.kt @@ -1,36 +1,7 @@ package com.follow.clash.service.models -import android.os.Parcel -import android.os.Parcelable - data class NotificationParams( val title: String = "FlClash", val stopText: String = "STOP", val onlyStatisticsProxy: Boolean = false, -) : Parcelable { - constructor(parcel: Parcel) : this( - title = parcel.readString() ?: "FlClash", - stopText = parcel.readString() ?: "STOP", - onlyStatisticsProxy = parcel.readByte() != 0.toByte(), - ) - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeString(title) - parcel.writeString(stopText) - parcel.writeByte(if (onlyStatisticsProxy) 1.toByte() else 0.toByte()) - } - - override fun describeContents(): Int { - return 0 - } - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): NotificationParams { - return NotificationParams(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } -} +) diff --git a/android/service/src/main/java/com/follow/clash/service/models/Traffic.kt b/android/service/src/main/java/com/follow/clash/service/models/Traffic.kt index a12c00d46c..cb5130dc31 100644 --- a/android/service/src/main/java/com/follow/clash/service/models/Traffic.kt +++ b/android/service/src/main/java/com/follow/clash/service/models/Traffic.kt @@ -1,25 +1,39 @@ package com.follow.clash.service.models import com.follow.clash.common.GlobalState -import com.follow.clash.common.formatBytes import com.follow.clash.core.Core import com.google.gson.Gson +private val gson = Gson() + data class Traffic( val up: Long, val down: Long, ) +private val Long.formatBytes: String + get() { + val units = arrayOf("B", "KB", "MB", "GB", "TB") + var value = toDouble() + var unit = 0 + while (value >= 1024 && unit < units.lastIndex) { + value /= 1024 + unit++ + } + return if (unit == 0) { + "${value.toLong()}${units[unit]}" + } else { + "%.1f${units[unit]}".format(value) + } + } + val Traffic.speedText: String get() = "${up.formatBytes}/sโ†‘ ${down.formatBytes}/sโ†“" fun Core.getSpeedTrafficText(onlyStatisticsProxy: Boolean): String { - try { - val res = getTraffic(onlyStatisticsProxy) - val traffic = Gson().fromJson(res, Traffic::class.java) - return traffic.speedText - } catch (e: Exception) { - GlobalState.log(e.message + "") - return "" - } -} \ No newline at end of file + return runCatching { + gson.fromJson(getTraffic(onlyStatisticsProxy), Traffic::class.java).speedText + }.onFailure { error -> + GlobalState.log("Unable to read traffic: $error") + }.getOrDefault("") +} diff --git a/android/service/src/main/java/com/follow/clash/service/models/VpnOptions.kt b/android/service/src/main/java/com/follow/clash/service/models/VpnOptions.kt index 27264aa0cb..3cf475201c 100644 --- a/android/service/src/main/java/com/follow/clash/service/models/VpnOptions.kt +++ b/android/service/src/main/java/com/follow/clash/service/models/VpnOptions.kt @@ -1,8 +1,8 @@ package com.follow.clash.service.models -import android.os.Parcel -import android.os.Parcelable import com.follow.clash.common.AccessControlMode +import java.net.Inet4Address +import java.net.Inet6Address import java.net.InetAddress data class AccessControlProps( @@ -10,35 +10,7 @@ data class AccessControlProps( val mode: AccessControlMode, val acceptList: List, val rejectList: List, -) : Parcelable { - constructor(parcel: Parcel) : this( - enable = parcel.readByte() != 0.toByte(), - mode = AccessControlMode.valueOf(parcel.readString() ?: AccessControlMode.ACCEPT_SELECTED.name), - acceptList = parcel.createStringArrayList() ?: emptyList(), - rejectList = parcel.createStringArrayList() ?: emptyList(), - ) - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeByte(if (enable) 1.toByte() else 0.toByte()) - parcel.writeString(mode.name) - parcel.writeStringList(acceptList) - parcel.writeStringList(rejectList) - } - - override fun describeContents(): Int { - return 0 - } - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): AccessControlProps { - return AccessControlProps(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } -} +) data class VpnOptions( val enable: Boolean, @@ -51,110 +23,32 @@ data class VpnOptions( val bypassDomain: List, val stack: String, val routeAddress: List, -) : Parcelable { - constructor(parcel: Parcel) : this( - enable = parcel.readByte() != 0.toByte(), - port = parcel.readInt(), - ipv6 = parcel.readByte() != 0.toByte(), - dnsHijacking = parcel.readByte() != 0.toByte(), - accessControlProps = readAccessControlProps(parcel), - allowBypass = parcel.readByte() != 0.toByte(), - systemProxy = parcel.readByte() != 0.toByte(), - bypassDomain = parcel.createStringArrayList() ?: emptyList(), - stack = parcel.readString() ?: "", - routeAddress = parcel.createStringArrayList() ?: emptyList(), - ) +) - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeByte(if (enable) 1.toByte() else 0.toByte()) - parcel.writeInt(port) - parcel.writeByte(if (ipv6) 1.toByte() else 0.toByte()) - parcel.writeByte(if (dnsHijacking) 1.toByte() else 0.toByte()) - parcel.writeParcelable(accessControlProps, flags) - parcel.writeByte(if (allowBypass) 1.toByte() else 0.toByte()) - parcel.writeByte(if (systemProxy) 1.toByte() else 0.toByte()) - parcel.writeStringList(bypassDomain) - parcel.writeString(stack) - parcel.writeStringList(routeAddress) - } - - override fun describeContents(): Int { - return 0 - } - - companion object CREATOR : Parcelable.Creator { - @Suppress("DEPRECATION") - private fun readAccessControlProps(parcel: Parcel): AccessControlProps { - return parcel.readParcelable( - AccessControlProps::class.java.classLoader, - ) ?: AccessControlProps( - enable = false, - mode = AccessControlMode.ACCEPT_SELECTED, - acceptList = emptyList(), - rejectList = emptyList(), - ) - } +data class CIDR( + val address: InetAddress, + val prefixLength: Int, +) - override fun createFromParcel(parcel: Parcel): VpnOptions { - return VpnOptions(parcel) - } +fun VpnOptions.getIpv4RouteAddress(): List = routeAddress + .map(String::toCIDR) + .filter { it.address is Inet4Address } - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } -} - -data class CIDR(val address: InetAddress, val prefixLength: Int) - -fun VpnOptions.getIpv4RouteAddress(): List { - return routeAddress.filter { - it.isIpv4() - }.map { - it.toCIDR() - } -} - -fun VpnOptions.getIpv6RouteAddress(): List { - return routeAddress.filter { - it.isIpv6() - }.map { - it.toCIDR() - } -} - -fun String.isIpv4(): Boolean { - val parts = split("/") - if (parts.size != 2) { - throw IllegalArgumentException("Invalid CIDR format") - } - val address = InetAddress.getByName(parts[0]) - return address.address.size == 4 -} - -fun String.isIpv6(): Boolean { - val parts = split("/") - if (parts.size != 2) { - throw IllegalArgumentException("Invalid CIDR format") - } - val address = InetAddress.getByName(parts[0]) - return address.address.size == 16 -} +fun VpnOptions.getIpv6RouteAddress(): List = routeAddress + .map(String::toCIDR) + .filter { it.address is Inet6Address } fun String.toCIDR(): CIDR { val parts = split("/") - if (parts.size != 2) { - throw IllegalArgumentException("Invalid CIDR format") - } + require(parts.size == 2) { "Invalid CIDR format: $this" } val ipAddress = parts[0] - val prefixLength = - parts[1].toIntOrNull() ?: throw IllegalArgumentException("Invalid prefix length") + val prefixLength = parts[1].toIntOrNull() + ?: throw IllegalArgumentException("Invalid prefix length: ${parts[1]}") val address = InetAddress.getByName(ipAddress) - val maxPrefix = if (address.address.size == 4) 32 else 128 - if (prefixLength < 0 || prefixLength > maxPrefix) { - throw IllegalArgumentException("Invalid prefix length for IP version") + require(prefixLength in 0..maxPrefix) { + "Invalid prefix length $prefixLength for $ipAddress" } return CIDR(address, prefixLength) diff --git a/android/service/src/main/java/com/follow/clash/service/modules/Module.kt b/android/service/src/main/java/com/follow/clash/service/modules/Module.kt deleted file mode 100644 index f2fb9cdffa..0000000000 --- a/android/service/src/main/java/com/follow/clash/service/modules/Module.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.follow.clash.service.modules - -abstract class Module { - - private var isInstall: Boolean = false - - protected abstract fun onInstall() - protected abstract fun onUninstall() - - fun install() { - isInstall = true - onInstall() - } - - fun uninstall() { - onUninstall() - isInstall = false - } -} \ No newline at end of file diff --git a/android/service/src/main/java/com/follow/clash/service/modules/ModuleLoader.kt b/android/service/src/main/java/com/follow/clash/service/modules/ModuleLoader.kt deleted file mode 100644 index d11215ca90..0000000000 --- a/android/service/src/main/java/com/follow/clash/service/modules/ModuleLoader.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.follow.clash.service.modules - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -interface ModuleLoaderScope { - fun install(module: T): T -} - -interface ModuleLoader { - fun load() - - fun cancel() -} - -private val mutex = Mutex() -fun CoroutineScope.moduleLoader(block: suspend ModuleLoaderScope.() -> Unit): ModuleLoader { - val modules = mutableListOf() - var job: Job? = null - - return object : ModuleLoader { - override fun load() { - job = launch(Dispatchers.IO) { - mutex.withLock { - val scope = object : ModuleLoaderScope { - override fun install(module: T): T { - modules.add(module) - module.install() - return module - } - } - scope.block() - } - } - } - - override fun cancel() { - launch(Dispatchers.IO) { - job?.cancel() - mutex.withLock { - modules.asReversed().forEach { it.uninstall() } - modules.clear() - } - } - } - } -} \ No newline at end of file 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 94e4f37a4a..153ea6ebe6 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 @@ -17,18 +17,20 @@ import java.net.InetAddress import java.util.concurrent.ConcurrentHashMap private data class NetworkInfo( - @Volatile var losingMs: Long = 0, @Volatile var dnsList: List = emptyList() + @Volatile var losingUntilMillis: Long = 0, + @Volatile var dnsList: List = emptyList(), ) { - fun isAvailable(): Boolean = losingMs < System.currentTimeMillis() + val priorityPenalty: Int + get() = if (losingUntilMillis > System.currentTimeMillis()) 10 else 0 } -class NetworkObserveModule(private val service: Service) : Module() { +internal class NetworkObserveModule(private val service: Service) : ServiceModule { private val networkInfos = ConcurrentHashMap() private val connectivity by lazy { service.getSystemService() } - private var preDnsList = listOf() + private var currentDnsList = listOf() private val request = NetworkRequest.Builder().apply { addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) @@ -42,108 +44,79 @@ class NetworkObserveModule(private val service: Service) : Module() { private val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { networkInfos[network] = NetworkInfo() - onUpdateNetwork() - super.onAvailable(network) + updateDns() } override fun onLosing(network: Network, maxMsToLive: Int) { - networkInfos[network]?.losingMs = System.currentTimeMillis() + maxMsToLive - onUpdateNetwork() - setUnderlyingNetworks(network) - super.onLosing(network, maxMsToLive) + networkInfos[network]?.losingUntilMillis = System.currentTimeMillis() + maxMsToLive + updateDns() } override fun onLost(network: Network) { networkInfos.remove(network) - onUpdateNetwork() - setUnderlyingNetworks(network) - super.onLost(network) + updateDns() } override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { networkInfos[network]?.dnsList = linkProperties.dnsServers - onUpdateNetwork() - setUnderlyingNetworks(network) - super.onLinkPropertiesChanged(network, linkProperties) + updateDns() } } - - override fun onInstall() { - onUpdateNetwork() + override fun start() { + updateDns() connectivity?.registerNetworkCallback(request, callback) } - private fun networkToInt(entry: Map.Entry): Int { + private fun networkPriority(entry: Map.Entry): Int { val capabilities = connectivity?.getNetworkCapabilities(entry.key) return when { capabilities == null -> 100 capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> 90 capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 0 capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 1 - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && capabilities.hasTransport( - TRANSPORT_USB - ) -> 2 + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + capabilities.hasTransport(TRANSPORT_USB) -> 2 capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> 3 capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 4 - Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && capabilities.hasTransport( - TRANSPORT_SATELLITE - ) -> 5 + Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && + capabilities.hasTransport(TRANSPORT_SATELLITE) -> 5 else -> 20 - } + (if (entry.value.isAvailable()) 0 else 10) + } + entry.value.priorityPenalty } - fun onUpdateNetwork() { - val dnsList = (networkInfos.asSequence().minByOrNull { networkToInt(it) }?.value?.dnsList - ?: emptyList()).map { x -> x.asSocketAddressText(53) } - if (dnsList == preDnsList) { + @Synchronized + private fun updateDns() { + val dnsList = networkInfos.asSequence() + .minByOrNull(::networkPriority) + ?.value + ?.dnsList + .orEmpty() + .map { address -> address.asSocketAddressText(DNS_PORT) } + .distinct() + if (dnsList == currentDnsList) { return } - preDnsList = dnsList - Core.updateDNS(dnsList.toSet().joinToString(",")) - } - - fun setUnderlyingNetworks(network: Network) { -// if (service is VpnService && Build.VERSION.SDK_INT in 22..28) { -// service.setUnderlyingNetworks(arrayOf(network)) -// } + currentDnsList = dnsList + Core.updateDNS(dnsList.joinToString(",")) } - override fun onUninstall() { - connectivity?.unregisterNetworkCallback(callback) - networkInfos.clear() - onUpdateNetwork() + override fun stop() { + try { + connectivity?.unregisterNetworkCallback(callback) + } finally { + networkInfos.clear() + updateDns() + } } } -fun InetAddress.asSocketAddressText(port: Int): String { - return when (this) { - is Inet6Address -> "[${numericToTextFormat(this)}]:$port" - - is Inet4Address -> "${this.hostAddress}:$port" +private const val DNS_PORT = 53 - else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}") - } +private fun InetAddress.asSocketAddressText(port: Int): String = when (this) { + is Inet6Address -> "[$hostAddress]:$port" + is Inet4Address -> "$hostAddress:$port" + else -> error("Unsupported address type: ${javaClass.name}") } - -private fun numericToTextFormat(address: Inet6Address): String { - val src = address.address - val sb = StringBuilder(39) - for (i in 0 until 8) { - sb.append( - Integer.toHexString( - src[i shl 1].toInt() shl 8 and 0xff00 or (src[(i shl 1) + 1].toInt() and 0xff) - ) - ) - if (i < 7) { - sb.append(":") - } - } - if (address.scopeId > 0) { - sb.append("%") - sb.append(address.scopeId) - } - return sb.toString() -} \ No newline at end of file diff --git a/android/service/src/main/java/com/follow/clash/service/modules/NotificationModule.kt b/android/service/src/main/java/com/follow/clash/service/modules/NotificationModule.kt index d68cb3abb0..045927a8f3 100644 --- a/android/service/src/main/java/com/follow/clash/service/modules/NotificationModule.kt +++ b/android/service/src/main/java/com/follow/clash/service/modules/NotificationModule.kt @@ -14,39 +14,41 @@ import com.follow.clash.common.QuickAction import com.follow.clash.common.quickIntent import com.follow.clash.common.receiveBroadcastFlow import com.follow.clash.common.startForeground -import com.follow.clash.common.tickerFlow import com.follow.clash.common.toPendingIntent import com.follow.clash.core.Core import com.follow.clash.service.R -import com.follow.clash.service.State +import com.follow.clash.service.ServiceConfig import com.follow.clash.service.models.NotificationParams import com.follow.clash.service.models.getSpeedTrafficText import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch -data class ExtendedNotificationParams( +private data class ExtendedNotificationParams( val title: String, val stopText: String, - val onlyStatisticsProxy: Boolean, val contentText: String, ) -val NotificationParams.extended: ExtendedNotificationParams +private val NotificationParams.extended: ExtendedNotificationParams get() = ExtendedNotificationParams( - title, stopText, onlyStatisticsProxy, Core.getSpeedTrafficText(onlyStatisticsProxy) + title, + stopText, + Core.getSpeedTrafficText(onlyStatisticsProxy), ) -class NotificationModule(private val service: Service) : Module() { - private val scope = CoroutineScope(Dispatchers.Default) - - override fun onInstall() { +internal class NotificationModule( + private val service: Service, + private val scope: CoroutineScope, +) : ServiceModule { + override fun start() { + update(ServiceConfig.notificationParams.value.extended) scope.launch { val screenFlow = service.receiveBroadcastFlow { addAction(Intent.ACTION_SCREEN_ON) @@ -58,41 +60,36 @@ class NotificationModule(private val service: Service) : Module() { } combine( - tickerFlow(1000, 0), State.notificationParamsFlow, screenFlow + flow { + while (true) { + delay(1_000) + emit(Unit) + } + }, + ServiceConfig.notificationParams, + screenFlow, ) { _, params, screenOn -> - params?.extended to screenOn - }.filter { (params, screenOn) -> params != null && screenOn } - .distinctUntilChanged { old, new -> old.first == new.first && old.second == new.second } - .collect { (params, _) -> - update(params!!) - } - - State.notificationParamsFlow.value?.let { - update(it.extended) - } ?: run { - update(NotificationParams().extended) - } + params.takeIf { screenOn }?.extended + }.filterNotNull() + .distinctUntilChanged() + .collect(::update) } } - private fun isScreenOn(): Boolean { - val pm = service.getSystemService() - return when (pm != null) { - true -> pm.isInteractive - false -> true - } - } + private fun isScreenOn() = + service.getSystemService()?.isInteractive ?: true private val notificationBuilder: NotificationCompat.Builder by lazy { - val intent = Intent().setComponent(Components.MAIN_ACTIVITY) + val intent = Intent().setComponent(Components.mainActivity) NotificationCompat.Builder( - service, GlobalState.NOTIFICATION_CHANNEL + service, + GlobalState.NOTIFICATION_CHANNEL, ).apply { setSmallIcon(R.drawable.ic_service) setContentTitle("FlClash") setContentIntent(intent.toPendingIntent) - setPriority(NotificationCompat.PRIORITY_HIGH) + setPriority(NotificationCompat.PRIORITY_LOW) setCategory(NotificationCompat.CATEGORY_SERVICE) setOngoing(true) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -100,9 +97,6 @@ class NotificationModule(private val service: Service) : Module() { } setShowWhen(true) setOnlyAlertOnce(true) -// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { -// setRequestPromotedOngoing(true) -// } } } @@ -113,17 +107,20 @@ class NotificationModule(private val service: Service) : Module() { setContentText(params.contentText) clearActions() addAction( - 0, params.stopText, QuickAction.STOP.quickIntent.toPendingIntent + 0, + params.stopText, + QuickAction.STOP.quickIntent.toPendingIntent, ).build() - }) + }, + ) } - override fun onUninstall() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + @Suppress("DEPRECATION") + override fun stop() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { service.stopForeground(STOP_FOREGROUND_REMOVE) } else { service.stopForeground(true) } - scope.cancel() } -} \ No newline at end of file +} diff --git a/android/service/src/main/java/com/follow/clash/service/modules/ServiceModules.kt b/android/service/src/main/java/com/follow/clash/service/modules/ServiceModules.kt new file mode 100644 index 0000000000..4bd97ad511 --- /dev/null +++ b/android/service/src/main/java/com/follow/clash/service/modules/ServiceModules.kt @@ -0,0 +1,59 @@ +package com.follow.clash.service.modules + +import android.app.Service +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +internal interface ServiceModule { + fun start() + + fun stop() +} + +internal class ServiceModules(private val service: Service) { + private var scope: CoroutineScope? = null + private var modules = emptyList() + + @Synchronized + fun start() { + if (scope != null) return + + val nextScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val nextModules = listOf( + NotificationModule(service, nextScope), + NetworkObserveModule(service), + SuspendModule(service, nextScope), + ) + val startedModules = mutableListOf() + + try { + nextModules.forEach { module -> + module.start() + startedModules.add(module) + } + scope = nextScope + modules = nextModules + } catch (error: Throwable) { + nextScope.cancel() + startedModules.asReversed().forEach { module -> + runCatching { module.stop() } + } + throw error + } + } + + @Synchronized + fun stop() { + val currentScope = scope ?: return + val currentModules = modules + scope = null + modules = emptyList() + + currentScope.cancel() + currentModules.asReversed().forEach { module -> + runCatching { module.stop() } + } + } +} diff --git a/android/service/src/main/java/com/follow/clash/service/modules/SuspendModule.kt b/android/service/src/main/java/com/follow/clash/service/modules/SuspendModule.kt index 5fd8d59a26..12f6930f05 100644 --- a/android/service/src/main/java/com/follow/clash/service/modules/SuspendModule.kt +++ b/android/service/src/main/java/com/follow/clash/service/modules/SuspendModule.kt @@ -7,55 +7,41 @@ import androidx.core.content.getSystemService import com.follow.clash.common.receiveBroadcastFlow import com.follow.clash.core.Core import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +internal class SuspendModule( + private val service: Service, + private val scope: CoroutineScope, +) : ServiceModule { + private fun isScreenOn() = + service.getSystemService()?.isInteractive ?: true -class SuspendModule(private val service: Service) : Module() { - private val scope = CoroutineScope(Dispatchers.Default) + private val isDeviceIdle: Boolean + get() = service.getSystemService()?.isDeviceIdleMode ?: true - private fun isScreenOn(): Boolean { - val pm = service.getSystemService() - return when (pm != null) { - true -> pm.isInteractive - false -> true - } - } - - val isDeviceIdleMode: Boolean - get() { - return service.getSystemService()?.isDeviceIdleMode ?: true - } - - private fun onUpdate(isScreenOn: Boolean) { - if (isScreenOn) { - Core.suspended(false) - return - } - Core.suspended(isDeviceIdleMode) + private fun updateSuspension(screenOn: Boolean) { + Core.suspended(!screenOn && isDeviceIdle) } - override fun onInstall() { + override fun start() { scope.launch { val screenFlow = service.receiveBroadcastFlow { addAction(Intent.ACTION_SCREEN_ON) addAction(Intent.ACTION_SCREEN_OFF) - }.map { intent -> - intent.action == Intent.ACTION_SCREEN_ON + addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED) + }.map { + isScreenOn() }.onStart { emit(isScreenOn()) } - screenFlow.collect { - onUpdate(it) - } + screenFlow.collect(::updateSuspension) } } - override fun onUninstall() { - scope.cancel() + override fun stop() { + Core.suspended(false) } -} \ No newline at end of file +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 5564c35eb7..145cd6f873 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -21,11 +21,10 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "9.0.1" apply false id("org.jetbrains.kotlin.android") version "2.4.0" apply false - id("com.google.gms.google-services") version ("4.5.0") apply false - id("com.google.firebase.crashlytics") version ("3.0.7") apply false + id("com.google.gms.google-services") version "4.5.0" apply false + id("com.google.firebase.crashlytics") version "3.0.7" apply false } - include(":app") include(":core") include(":service") diff --git a/arb/intl_en.arb b/arb/intl_en.arb index 6951fb3a5f..f48053175f 100644 --- a/arb/intl_en.arb +++ b/arb/intl_en.arb @@ -318,6 +318,8 @@ "messageTest": "Message test", "messageTestTip": "This is a message.", "crashTest": "Crash test", + "crashDetected": "Crash detected", + "crashDetectedTip": "The app crashed during the previous run. To prevent repeated crashes, the current profile has been cleared and automatic configuration setup was skipped.", "clearData": "Clear Data", "textScale": "Text Scaling", "internet": "Internet", @@ -547,7 +549,7 @@ "hoursCount": "{count} hours", "geoResources": "Geo Resources", "geoUpdating": "Updating {name}...", - "geoSkipped": "{name} skipped", + "geoSkipped": "{name} is already up to date", "geoUpdated": "{name} updated", "secondsCount": "{count} seconds", "entriesCount": "{count} entries" diff --git a/arb/intl_ja.arb b/arb/intl_ja.arb index a310126c0e..3c933bc4ab 100644 --- a/arb/intl_ja.arb +++ b/arb/intl_ja.arb @@ -318,6 +318,8 @@ "messageTest": "ใƒกใƒƒใ‚ปใƒผใ‚ธใƒ†ใ‚นใƒˆ", "messageTestTip": "ใ“ใ‚Œใฏใƒกใƒƒใ‚ปใƒผใ‚ธใงใ™ใ€‚", "crashTest": "ใ‚ฏใƒฉใƒƒใ‚ทใƒฅใƒ†ใ‚นใƒˆ", + "crashDetected": "ใ‚ฏใƒฉใƒƒใ‚ทใƒฅใ‚’ๆคœๅ‡บใ—ใพใ—ใŸ", + "crashDetectedTip": "ๅ‰ๅ›žใฎๅฎŸ่กŒไธญใซใ‚ขใƒ—ใƒชใŒใ‚ฏใƒฉใƒƒใ‚ทใƒฅใ—ใพใ—ใŸใ€‚ใ‚ฏใƒฉใƒƒใ‚ทใƒฅใฎ็นฐใ‚Š่ฟ”ใ—ใ‚’้˜ฒใใŸใ‚ใ€็พๅœจใฎใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’่งฃ้™คใ—ใ€่จญๅฎšใฎ่‡ชๅ‹•ใ‚ปใƒƒใƒˆใ‚ขใƒƒใƒ—ใ‚’ใ‚นใ‚ญใƒƒใƒ—ใ—ใพใ—ใŸใ€‚", "clearData": "ใƒ‡ใƒผใ‚ฟใ‚’ๆถˆๅŽป", "textScale": "ใƒ†ใ‚ญใ‚นใƒˆใ‚นใ‚ฑใƒผใƒชใƒณใ‚ฐ", "internet": "ใ‚คใƒณใ‚ฟใƒผใƒใƒƒใƒˆ", @@ -547,7 +549,7 @@ "hoursCount": "{count} ๆ™‚้–“", "geoResources": "Geoใƒชใ‚ฝใƒผใ‚น", "geoUpdating": "{name}ใ‚’ๆ›ดๆ–ฐไธญ...", - "geoSkipped": "{name} ใ‚นใ‚ญใƒƒใƒ—ๆธˆใฟ", + "geoSkipped": "{name} ใฏใ™ใงใซๆœ€ๆ–ฐใงใ™", "geoUpdated": "{name} ๆ›ดๆ–ฐๆธˆใฟ", "secondsCount": "{count} ็ง’", "entriesCount": "{count} ใ‚จใƒณใƒˆใƒช" diff --git a/arb/intl_ru.arb b/arb/intl_ru.arb index 4cdfdf3036..14738159fa 100644 --- a/arb/intl_ru.arb +++ b/arb/intl_ru.arb @@ -318,6 +318,8 @@ "messageTest": "ะขะตัั‚ะธั€ะพะฒะฐะฝะธะต ัะพะพะฑั‰ะตะฝะธั", "messageTestTip": "ะญั‚ะพ ัะพะพะฑั‰ะตะฝะธะต.", "crashTest": "ะขะตัั‚ ะฝะฐ ัะฑะพะธ", + "crashDetected": "ะžะฑะฝะฐั€ัƒะถะตะฝ ัะฑะพะน", + "crashDetectedTip": "ะ’ะพ ะฒั€ะตะผั ะฟั€ะตะดั‹ะดัƒั‰ะตะณะพ ะทะฐะฟัƒัะบะฐ ะฟั€ะพะธะทะพัˆั‘ะป ัะฑะพะน ะฟั€ะธะปะพะถะตะฝะธั. ะงั‚ะพะฑั‹ ะฟั€ะตะดะพั‚ะฒั€ะฐั‚ะธั‚ัŒ ะฟะพะฒั‚ะพั€ะฝั‹ะน ัะฑะพะน, ั‚ะตะบัƒั‰ะธะน ะฟั€ะพั„ะธะปัŒ ะฑั‹ะป ัะฑั€ะพัˆะตะฝ, ะฐ ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะฐั ะฝะฐัั‚ั€ะพะนะบะฐ ะบะพะฝั„ะธะณัƒั€ะฐั†ะธะธ ะฟั€ะพะฟัƒั‰ะตะฝะฐ.", "clearData": "ะžั‡ะธัั‚ะธั‚ัŒ ะดะฐะฝะฝั‹ะต", "textScale": "ะœะฐััˆั‚ะฐะฑะธั€ะพะฒะฐะฝะธะต ั‚ะตะบัั‚ะฐ", "internet": "ะ˜ะฝั‚ะตั€ะฝะตั‚", @@ -547,7 +549,7 @@ "hoursCount": "{count} ั‡ะฐัะพะฒ", "geoResources": "ะ ะตััƒั€ัั‹ Geo", "geoUpdating": "ะžะฑะฝะพะฒะปะตะฝะธะต {name}...", - "geoSkipped": "{name} ะฟั€ะพะฟัƒั‰ะตะฝะพ", + "geoSkipped": "ะ”ะปั {name} ัƒะถะต ัƒัั‚ะฐะฝะพะฒะปะตะฝะฐ ะฟะพัะปะตะดะฝัั ะฒะตั€ัะธั", "geoUpdated": "{name} ะพะฑะฝะพะฒะปะตะฝะพ", "secondsCount": "{count} ัะตะบัƒะฝะด", "entriesCount": "{count} ะทะฐะฟะธัะตะน" diff --git a/arb/intl_zh_CN.arb b/arb/intl_zh_CN.arb index 28435c1095..35ca45259b 100644 --- a/arb/intl_zh_CN.arb +++ b/arb/intl_zh_CN.arb @@ -318,6 +318,8 @@ "messageTest": "ๆถˆๆฏๆต‹่ฏ•", "messageTestTip": "่ฟ™ๆ˜ฏไธ€ๆกๆถˆๆฏใ€‚", "crashTest": "ๅดฉๆบƒๆต‹่ฏ•", + "crashDetected": "ๆฃ€ๆต‹ๅˆฐๅดฉๆบƒ", + "crashDetectedTip": "ๆฃ€ๆต‹ๅˆฐๅบ”็”จไธŠๆฌก่ฟ่กŒๅ‘็”Ÿๅดฉๆบƒใ€‚ไธบ้ฟๅ…้‡ๅคๅดฉๆบƒ๏ผŒๅทฒๆธ…้™คๅฝ“ๅ‰้…็ฝฎ้€‰ๆ‹ฉ๏ผŒๅนถ่ทณ่ฟ‡ๆœฌๆฌก่‡ชๅŠจ้…็ฝฎใ€‚", "clearData": "ๆธ…้™คๆ•ฐๆฎ", "textScale": "ๆ–‡ๆœฌ็ผฉๆ”พ", "internet": "ไบ’่”็ฝ‘", @@ -547,7 +549,7 @@ "hoursCount": "{count} ๅฐๆ—ถ", "geoResources": "Geo ่ต„ๆบ", "geoUpdating": "ๆญฃๅœจๆ›ดๆ–ฐ {name}...", - "geoSkipped": "{name} ๅทฒ่ทณ่ฟ‡", + "geoSkipped": "{name} ๅทฒๆ˜ฏๆœ€ๆ–ฐ็‰ˆๆœฌ", "geoUpdated": "{name} ๅทฒๆ›ดๆ–ฐ", "secondsCount": "{count} ็ง’", "entriesCount": "{count} ไธชๆก็›ฎ" diff --git a/core/Clash.Meta b/core/Clash.Meta index 80362fc189..1691c15bf2 160000 --- a/core/Clash.Meta +++ b/core/Clash.Meta @@ -1 +1 @@ -Subproject commit 80362fc1895dcf60b79b562896653046e0687413 +Subproject commit 1691c15bf23909ba7181406dfe196c780f0a2e6f diff --git a/core/action.go b/core/action.go deleted file mode 100644 index a7d6c8f917..0000000000 --- a/core/action.go +++ /dev/null @@ -1,193 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "runtime" - "unsafe" -) - -type Action struct { - Id string `json:"id"` - Method Method `json:"method"` - Data interface{} `json:"data"` -} - -type ActionResult struct { - Id string `json:"id"` - Method Method `json:"method"` - Data interface{} `json:"data"` - Code int `json:"code"` - callback unsafe.Pointer -} - -func (result ActionResult) Json() ([]byte, error) { - data, err := json.Marshal(result) - return data, err -} - -func (result ActionResult) success(data interface{}) { - result.Code = 0 - result.Data = data - result.send() -} - -func (result ActionResult) error(data interface{}) { - result.Code = -1 - result.Data = data - result.send() -} - -func handleAction(action *Action, result ActionResult) { - defer func() { - if r := recover(); r != nil { - buf := make([]byte, 4096) - n := runtime.Stack(buf, false) - logError("panic in handleAction(%s): %v\n%s", action.Method, r, buf[:n]) - result.error(fmt.Sprintf("internal panic: %v", r)) - } - }() - switch action.Method { - case initClashMethod: - paramsString := action.Data.(string) - result.success(handleInitClash(paramsString)) - return - case getIsInitMethod: - result.success(handleGetIsInit()) - return - case forceGcMethod: - handleForceGC() - result.success(true) - return - case shutdownMethod: - result.success(handleShutdown()) - return - case validateConfigMethod: - path := action.Data.(string) - result.success(handleValidateConfig(path)) - return - case updateConfigMethod: - data := []byte(action.Data.(string)) - result.success(handleUpdateConfig(data)) - return - case setupConfigMethod: - data := []byte(action.Data.(string)) - result.success(handleSetupConfig(data)) - return - case getProxiesMethod: - result.success(handleGetProxies()) - return - case changeProxyMethod: - data := action.Data.(string) - handleChangeProxy(data, func(value string) { - result.success(value) - }) - return - case getTrafficMethod: - data := action.Data.(bool) - result.success(handleGetTraffic(data)) - return - case getTotalTrafficMethod: - data := action.Data.(bool) - result.success(handleGetTotalTraffic(data)) - return - case resetTrafficMethod: - handleResetTraffic() - result.success(true) - return - case asyncTestDelayMethod: - data := action.Data.(string) - handleAsyncTestDelay(data, func(value string) { - result.success(value) - }) - return - case getConnectionsMethod: - result.success(handleGetConnections()) - return - case closeConnectionsMethod: - result.success(handleCloseConnections()) - return - case resetConnectionsMethod: - result.success(handleResetConnections()) - return - case getConfigMethod: - path := action.Data.(string) - config, err := handleGetConfig(path) - if err != nil { - result.error(err) - return - } - result.success(config) - return - case closeConnectionMethod: - id := action.Data.(string) - result.success(handleCloseConnection(id)) - return - case getExternalProvidersMethod: - result.success(handleGetExternalProviders()) - return - case getExternalProviderMethod: - externalProviderName := action.Data.(string) - result.success(handleGetExternalProvider(externalProviderName)) - return - case updateGeoDataMethod: - geoType := action.Data.(string) - handleUpdateGeoData(geoType) - result.success("") - return - case updateExternalProviderMethod: - providerName := action.Data.(string) - handleUpdateExternalProvider(providerName, func(value string) { - result.success(value) - }) - return - case sideLoadExternalProviderMethod: - paramsString := action.Data.(string) - var params = map[string]string{} - err := json.Unmarshal([]byte(paramsString), ¶ms) - if err != nil { - result.success(err.Error()) - return - } - providerName := params["providerName"] - data := params["data"] - handleSideLoadExternalProvider(providerName, []byte(data), func(value string) { - result.success(value) - }) - return - case startLogMethod: - handleStartLog() - result.success(true) - return - case stopLogMethod: - handleStopLog() - result.success(true) - return - case startListenerMethod: - result.success(handleStartListener()) - return - case stopListenerMethod: - result.success(handleStopListener()) - return - case getCountryCodeMethod: - ip := action.Data.(string) - handleGetCountryCode(ip, func(value string) { - result.success(value) - }) - return - case getMemoryMethod: - handleGetMemory(func(value string) { - result.success(value) - }) - return - case crashMethod: - result.success(true) - handleCrash() - case deleteFile: - path := action.Data.(string) - handleDelFile(path, result) - return - default: - nextHandle(action, result) - } -} diff --git a/core/common.go b/core/common.go index 46d9bdfdf8..13717488d2 100644 --- a/core/common.go +++ b/core/common.go @@ -229,11 +229,21 @@ func updateConfig(params *UpdateParams) { if params.Tun != nil { general.Tun.Enable = params.Tun.Enable - general.Tun.AutoRoute = *params.Tun.AutoRoute - general.Tun.Device = *params.Tun.Device - general.Tun.RouteAddress = *params.Tun.RouteAddress - general.Tun.DNSHijack = *params.Tun.DNSHijack - general.Tun.Stack = *params.Tun.Stack + 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 + } } if params.GeoAutoUpdate != nil { diff --git a/core/constant.go b/core/constant.go index ed16fea2eb..f7ae1a4453 100644 --- a/core/constant.go +++ b/core/constant.go @@ -48,8 +48,8 @@ type tunSchema struct { } type ChangeProxyParams struct { - GroupName *string `json:"group-name"` - ProxyName *string `json:"proxy-name"` + GroupName string `json:"group-name"` + ProxyName string `json:"proxy-name"` } type TestDelayParams struct { @@ -58,6 +58,11 @@ type TestDelayParams struct { Timeout int64 `json:"timeout"` } +type Traffic struct { + Up int64 `json:"up"` + Down int64 `json:"down"` +} + type ExternalProvider struct { Name string `json:"name"` Type string `json:"type"` @@ -74,42 +79,42 @@ type ProxiesData struct { } const ( - messageMethod Method = "message" - initClashMethod Method = "initClash" - getIsInitMethod Method = "getIsInit" - forceGcMethod Method = "forceGc" - shutdownMethod Method = "shutdown" - validateConfigMethod Method = "validateConfig" - updateConfigMethod Method = "updateConfig" - getProxiesMethod Method = "getProxies" - changeProxyMethod Method = "changeProxy" - getTrafficMethod Method = "getTraffic" - getTotalTrafficMethod Method = "getTotalTraffic" - resetTrafficMethod Method = "resetTraffic" - asyncTestDelayMethod Method = "asyncTestDelay" - getConnectionsMethod Method = "getConnections" - closeConnectionsMethod Method = "closeConnections" - resetConnectionsMethod Method = "resetConnections" - closeConnectionMethod Method = "closeConnection" - getExternalProvidersMethod Method = "getExternalProviders" - getExternalProviderMethod Method = "getExternalProvider" - getCountryCodeMethod Method = "getCountryCode" - getMemoryMethod Method = "getMemory" - updateGeoDataMethod Method = "updateGeoData" - updateExternalProviderMethod Method = "updateExternalProvider" - sideLoadExternalProviderMethod Method = "sideLoadExternalProvider" - startLogMethod Method = "startLog" - stopLogMethod Method = "stopLog" - startListenerMethod Method = "startListener" - stopListenerMethod Method = "stopListener" - updateDnsMethod Method = "updateDns" - crashMethod Method = "crash" - setupConfigMethod Method = "setupConfig" - getConfigMethod Method = "getConfig" - deleteFile Method = "deleteFile" + messageMethod CoreMethod = "message" + initClashMethod CoreMethod = "initClash" + getIsInitMethod CoreMethod = "getIsInit" + forceGcMethod CoreMethod = "forceGc" + shutdownMethod CoreMethod = "shutdown" + validateConfigMethod CoreMethod = "validateConfig" + updateConfigMethod CoreMethod = "updateConfig" + getProxiesMethod CoreMethod = "getProxies" + changeProxyMethod CoreMethod = "changeProxy" + getTrafficMethod CoreMethod = "getTraffic" + getTotalTrafficMethod CoreMethod = "getTotalTraffic" + resetTrafficMethod CoreMethod = "resetTraffic" + asyncTestDelayMethod CoreMethod = "asyncTestDelay" + getConnectionsMethod CoreMethod = "getConnections" + closeConnectionsMethod CoreMethod = "closeConnections" + resetConnectionsMethod CoreMethod = "resetConnections" + closeConnectionMethod CoreMethod = "closeConnection" + getExternalProvidersMethod CoreMethod = "getExternalProviders" + getExternalProviderMethod CoreMethod = "getExternalProvider" + getCountryCodeMethod CoreMethod = "getCountryCode" + getMemoryMethod CoreMethod = "getMemory" + updateGeoDataMethod CoreMethod = "updateGeoData" + updateExternalProviderMethod CoreMethod = "updateExternalProvider" + sideLoadExternalProviderMethod CoreMethod = "sideLoadExternalProvider" + startLogMethod CoreMethod = "startLog" + stopLogMethod CoreMethod = "stopLog" + startListenerMethod CoreMethod = "startListener" + stopListenerMethod CoreMethod = "stopListener" + updateDnsMethod CoreMethod = "updateDns" + crashMethod CoreMethod = "crash" + setupConfigMethod CoreMethod = "setupConfig" + getConfigMethod CoreMethod = "getConfig" + clearEffectMethod CoreMethod = "clearEffect" ) -type Method string +type CoreMethod string type MessageType string diff --git a/core/go.mod b/core/go.mod index e04990f85e..fc40d0cf7c 100644 --- a/core/go.mod +++ b/core/go.mod @@ -14,7 +14,7 @@ require ( require ( github.com/RyuaNerin/go-krypto v1.3.0 // indirect github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect - github.com/ajg/form v1.5.1 // 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 @@ -25,7 +25,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.33.0 // indirect + github.com/enfein/mieru/v3 v3.34.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 @@ -55,13 +55,13 @@ 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-20251104174305-5a0e9f7e361d // indirect + github.com/metacubex/amneziawg-go v0.0.0-20260612143004-19b4f1cdd5ec // indirect github.com/metacubex/ascon v0.1.0 // indirect github.com/metacubex/bart v0.26.0 // indirect - github.com/metacubex/bbolt v0.0.0-20250725135710-010dbbbb7a5b // 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.0 // indirect + github.com/metacubex/chi v0.1.1 // indirect github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a // indirect github.com/metacubex/cpu v0.1.1 // indirect github.com/metacubex/edwards25519 v1.2.0 // indirect @@ -71,32 +71,33 @@ require ( 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/kcp-go v0.0.0-20260105040817-550693377604 // indirect github.com/metacubex/mhurl v0.1.0 // 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.20260520020949-fcd18c7b6ace // indirect + github.com/metacubex/quic-go v0.59.1-0.20260606115121-0662b57ad5bf // indirect github.com/metacubex/randv2 v0.2.0 // indirect - github.com/metacubex/restls-client-go v0.1.7 // indirect + github.com/metacubex/restls-client-go v0.1.8 // indirect github.com/metacubex/sevenzip v1.6.4 // indirect github.com/metacubex/sing v0.5.7 // indirect - github.com/metacubex/sing-mux v0.3.9 // 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-shadowsocks v0.2.12 // indirect github.com/metacubex/sing-shadowsocks2 v0.2.7 // indirect - github.com/metacubex/sing-shadowtls v0.0.0-20260517015314-c11c36474edc // indirect - github.com/metacubex/sing-tun v0.4.20 // indirect + github.com/metacubex/sing-tun v0.4.21 // indirect github.com/metacubex/sing-vmess v0.2.5 // indirect github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c // 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-20260520011538-f23132fac4b7 // indirect - github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af // indirect - github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443 // indirect - github.com/metacubex/tls v0.1.6 // indirect - github.com/metacubex/utls v1.8.4 // 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/tfo-go v0.0.0-20260623020846-376a77860b8c // indirect + github.com/metacubex/tls v0.1.7 // 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/miekg/dns v1.1.63 // indirect diff --git a/core/go.sum b/core/go.sum index f9206d6849..b8aa979025 100644 --- a/core/go.sum +++ b/core/go.sum @@ -6,8 +6,8 @@ github.com/RyuaNerin/testingutil v0.1.0 h1:IYT6JL57RV3U2ml3dLHZsVtPOP6yNK7WUVdzz github.com/RyuaNerin/testingutil v0.1.0/go.mod h1:yTqj6Ta/ycHMPJHRyO12Mz3VrvTloWOsy23WOZH19AA= github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 h1:cDVUiFo+npB0ZASqnw4q90ylaVAbnYyx0JYqK4YcGok= github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344/go.mod h1:9pIqrY6SXNL8vjRQE5Hd/OL5GyK/9MrGUWs87z/eFfk= -github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= -github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +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= @@ -34,8 +34,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.33.0 h1:hv2jK8nqYHwpSG86U2rpZR2I8Aff1/J3ifRmd9NBbFc= -github.com/enfein/mieru/v3 v3.33.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= +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/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= @@ -106,20 +106,20 @@ 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-20251104174305-5a0e9f7e361d h1:vAJ0ZT4aO803F1uw2roIA9yH7Sxzox34tVVyye1bz6c= -github.com/metacubex/amneziawg-go v0.0.0-20251104174305-5a0e9f7e361d/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY= +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/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/bbolt v0.0.0-20250725135710-010dbbbb7a5b h1:j7dadXD8I2KTmMt8jg1JcaP1ANL3JEObJPdANKcSYPY= -github.com/metacubex/bbolt v0.0.0-20250725135710-010dbbbb7a5b/go.mod h1:+WmP0VJZDkDszvpa83HzfUp6QzARl/IKkMorH4+nODw= +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= github.com/metacubex/blake3 v0.1.0/go.mod h1:CCkLdzFrqf7xmxCdhQFvJsRRV2mwOLDoSPg6vUTB9Uk= github.com/metacubex/chacha v0.1.5 h1:fKWMb/5c7ZrY8Uoqi79PPFxl+qwR7X/q0OrsAubyX2M= github.com/metacubex/chacha v0.1.5/go.mod h1:Djn9bPZxLTXbJFSeyo0/qzEzQI+gUSSzttuzZM75GH8= -github.com/metacubex/chi v0.1.0 h1:rjNDyDj50nRpicG43CNkIw4ssiCbmDL8d7wJXKlUCsg= -github.com/metacubex/chi v0.1.0/go.mod h1:zM5u5oMQt8b2DjvDHvzadKrP6B2ztmasL1YHRMbVV+g= +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/cpu v0.1.1 h1:rRV5HGmeuGzjiKI3hYbL0dCd0qGwM7VUtk4ICXD06mI= @@ -138,6 +138,10 @@ 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/kcp-go v0.0.0-20260105040817-550693377604 h1:hJwCVlE3ojViC35MGHB+FBr8TuIf3BUFn2EQ1VIamsI= @@ -150,28 +154,26 @@ github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb h1:wk6mHYPURSUv 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.20260520020949-fcd18c7b6ace h1:KXacx7dp1GYVMgxezwXRt5BMsEbvAYuA6rPFUmdAvcQ= -github.com/metacubex/quic-go v0.59.1-0.20260520020949-fcd18c7b6ace/go.mod h1:2YEQEvFrZ5V76oynMBDTlN+4fdnSHCa2uNJxv3cm1HU= +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/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.7 h1:eCwiXCTQb5WJu9IlgYvDBA1OgrINv58dEe7hcN5H15k= -github.com/metacubex/restls-client-go v0.1.7/go.mod h1:BN/U52vPw7j8VTSh2vleD/MnmVKCov84mS5VcjVHH4g= +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/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.9 h1:/aoBD2+sK2qsXDlNDe3hkR0GZuFDtwIZhOeGUx9W0Yk= -github.com/metacubex/sing-mux v0.3.9/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc= +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-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-shadowtls v0.0.0-20260517015314-c11c36474edc h1:8wLoFfYQ88iGPL+krQ5tJsI8IAmkFjKpQL2q+y3pvss= -github.com/metacubex/sing-shadowtls v0.0.0-20260517015314-c11c36474edc/go.mod h1:mbfboaXauKJNIHJYxQRa+NJs4JU9NZfkA+I33dS2+9E= -github.com/metacubex/sing-tun v0.4.20 h1:xdupzizRoZKyDzP0l68WAx5Sk4ooiuT1GiWsiJyOGPw= -github.com/metacubex/sing-tun v0.4.20/go.mod h1:g4I/JNplDBhXLF+aQWgFbhNeJPSXQOWS9HvLeNvkgeA= +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-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= @@ -180,16 +182,16 @@ github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 h1:DK2l6m2Fc85H2Bhi 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-20260520011538-f23132fac4b7 h1:LoJR4NMyNKHeEJoeGDtcsao7sV0NRkzMeV5H/0J0MIE= -github.com/metacubex/tailscale v0.0.0-20260520011538-f23132fac4b7/go.mod h1:MAo3HhE7968rIwmDvYTYE8xCsV4x+hLnkChdXeP3X4c= -github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af h1:c60IbBMUq2h1M2m7+grMJJmBmrObxL8SwvNtm6Ozbwk= -github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af/go.mod h1:i3zLKytWkOnyT1i9OmiLevWvrN5J5HE1+yjE7UYNfcQ= -github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443 h1:H6TnfM12tOoTizYE/qBHH3nEuibIelmHI+BVSxVJr8o= -github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw= -github.com/metacubex/tls v0.1.6 h1:t2ubLneYa4ceyIC++54a57BLqZFA/QYUrhdjLk2GPwo= -github.com/metacubex/tls v0.1.6/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM= -github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= -github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +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/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/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= diff --git a/core/hub.go b/core/hub.go index aad920838f..ac3e8a3641 100644 --- a/core/hub.go +++ b/core/hub.go @@ -3,7 +3,6 @@ package main import ( "cmp" "context" - "encoding/json" "github.com/metacubex/mihomo/adapter" "github.com/metacubex/mihomo/adapter/outboundgroup" "github.com/metacubex/mihomo/common/observable" @@ -23,30 +22,27 @@ import ( "golang.org/x/exp/slices" "net" "os" + "path/filepath" "runtime" "runtime/debug" "strconv" + "sync/atomic" "time" ) var ( - isInit = false + isInit atomic.Bool externalProviders = map[string]cp.Provider{} logSubscriber observable.Subscription[log.Event] ) -func handleInitClash(paramsString string) bool { +func handleInitClash(params *InitParams) bool { runLock.Lock() defer runLock.Unlock() - var params = InitParams{} - err := json.Unmarshal([]byte(paramsString), ¶ms) - if err != nil { - return false - } version = params.Version constant.SetHomeDir(params.HomeDir) - isInit = true - return isInit + isInit.Store(true) + return true } func handleStartListener() bool { @@ -68,7 +64,7 @@ func handleStopListener() bool { } func handleGetIsInit() bool { - return isInit + return isInit.Load() } func handleForceGC() { @@ -83,7 +79,7 @@ func handleShutdown() bool { stopListeners() executor.Shutdown() handleForceGC() - isInit = false + isInit.Store(false) return true } @@ -136,25 +132,23 @@ func handleGetProxies() ProxiesData { } } -func handleChangeProxy(data string, fn func(string string)) { +func handleChangeProxy(params *ChangeProxyParams, fn func(string string)) { runLock.Lock() go func() { defer runLock.Unlock() - var params = &ChangeProxyParams{} - err := json.Unmarshal([]byte(data), params) - if err != nil { - fn(err.Error()) - return - } - groupName := *params.GroupName - proxyName := *params.ProxyName + groupName := params.GroupName + proxyName := params.ProxyName proxies := tunnel.AllProxies() group, ok := proxies[groupName] if !ok { fn("Not found group") return } - adapterProxy := group.(*adapter.Proxy) + 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") @@ -163,11 +157,11 @@ func handleChangeProxy(data string, fn func(string string)) { if proxyName == "" { selector.ForceSet(proxyName) } else { - err = selector.Set(proxyName) - } - if err != nil { - fn(err.Error()) - return + err := selector.Set(proxyName) + if err != nil { + fn(err.Error()) + return + } } fn("") @@ -175,50 +169,42 @@ func handleChangeProxy(data string, fn func(string string)) { }() } -func handleGetTraffic(onlyStatisticsProxy bool) string { +func handleGetTraffic(onlyStatisticsProxy bool) Traffic { up, down := statistic.DefaultManager.NowTraffic(onlyStatisticsProxy) - traffic := map[string]int64{ - "up": up, - "down": down, - } - data, err := json.Marshal(traffic) - if err != nil { - logError("Error: %s", err) - return "" + return Traffic{ + Up: up, + Down: down, } - return string(data) } -func handleGetTotalTraffic(onlyStatisticsProxy bool) string { +func handleGetTotalTraffic(onlyStatisticsProxy bool) Traffic { up, down := statistic.DefaultManager.TotalTraffic(onlyStatisticsProxy) - traffic := map[string]int64{ - "up": up, - "down": down, - } - data, err := json.Marshal(traffic) - if err != nil { - logError("Error: %s", err) - return "" + return Traffic{ + Up: up, + Down: down, } - return string(data) } func handleResetTraffic() { statistic.DefaultManager.ResetStatistic() } -func handleAsyncTestDelay(paramsString string, fn func(string)) { - mBatch.Go(paramsString, func() (bool, error) { - var params = &TestDelayParams{} - err := json.Unmarshal([]byte(paramsString), params) - if err != nil { - fn("") - return false, nil +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, } expectedStatus, err := utils.NewUnsignedRanges[uint16]("") if err != nil { - fn("") + fn(delayData) return false, nil } @@ -228,48 +214,26 @@ func handleAsyncTestDelay(paramsString string, fn func(string)) { proxies := tunnel.AllProxies() proxy := proxies[params.ProxyName] - delayData := &Delay{ - Name: params.ProxyName, - } - if proxy == nil { - delayData.Value = -1 - data, _ := json.Marshal(delayData) - fn(string(data)) + fn(delayData) return false, nil } - - testUrl := constant.DefaultTestURL - - if params.TestUrl != "" { - testUrl = params.TestUrl - } - delayData.Url = testUrl delay, err := proxy.URLTest(ctx, testUrl, expectedStatus) if err != nil || delay == 0 { - delayData.Value = -1 - data, _ := json.Marshal(delayData) - fn(string(data)) + fn(delayData) return false, nil } delayData.Value = int32(delay) - data, _ := json.Marshal(delayData) - fn(string(data)) + fn(delayData) return false, nil }) } -func handleGetConnections() string { +func handleGetConnections() *statistic.Snapshot { runLock.Lock() defer runLock.Unlock() - snapshot := statistic.DefaultManager.Snapshot() - data, err := json.Marshal(snapshot) - if err != nil { - logError("Error: %s", err) - return "" - } - return string(data) + return statistic.DefaultManager.Snapshot() } func handleCloseConnections() bool { @@ -307,7 +271,7 @@ func handleCloseConnection(connectionId string) bool { return true } -func handleGetExternalProviders() string { +func handleGetExternalProviders() []ExternalProvider { runLock.Lock() defer runLock.Unlock() externalProviders = getExternalProvidersRaw() @@ -322,29 +286,21 @@ func handleGetExternalProviders() string { slices.SortFunc(eps, func(a, b ExternalProvider) int { return cmp.Compare(a.Name, b.Name) }) - data, err := json.Marshal(eps) - if err != nil { - return "" - } - return string(data) + return eps } -func handleGetExternalProvider(externalProviderName string) string { +func handleGetExternalProvider(externalProviderName string) *ExternalProvider { runLock.Lock() defer runLock.Unlock() externalProvider, exist := externalProviders[externalProviderName] if !exist { - return "" + return nil } e, err := toExternalProvider(externalProvider) if err != nil { - return "" - } - data, err := json.Marshal(e) - if err != nil { - return "" + return nil } - return string(data) + return e } func handleUpdateGeoData(geoType string) { @@ -368,7 +324,9 @@ func handleUpdateGeoData(geoType string) { 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 @@ -410,13 +368,15 @@ func handleSuspend(suspended bool) bool { } func handleStartLog() { + runLock.Lock() if logSubscriber != nil { log.UnSubscribe(logSubscriber) - logSubscriber = nil } - logSubscriber = log.Subscribe() + subscriber := log.Subscribe() + logSubscriber = subscriber + runLock.Unlock() go func() { - for logData := range logSubscriber { + for logData := range subscriber { if logData.LogLevel < log.Level() { continue } @@ -430,6 +390,8 @@ func handleStartLog() { } func handleStopLog() { + runLock.Lock() + defer runLock.Unlock() if logSubscriber != nil { log.UnSubscribe(logSubscriber) logSubscriber = nil @@ -449,9 +411,9 @@ func handleGetCountryCode(ip string, fn func(value string)) { }() } -func handleGetMemory(fn func(value string)) { +func handleGetMemory(fn func(value uint64)) { go func() { - fn(strconv.FormatUint(statistic.DefaultManager.Memory(), 10)) + fn(statistic.DefaultManager.Memory()) }() } @@ -471,55 +433,46 @@ func handleCrash() { panic("handle invoke crash") } -func handleUpdateConfig(bytes []byte) string { - var params = &UpdateParams{} - err := json.Unmarshal(bytes, params) - if err != nil { - return err.Error() - } +func handleUpdateConfig(params *UpdateParams) string { updateConfig(params) return "" } -func handleDelFile(path string, result ActionResult) { +// 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() { - fileInfo, err := os.Stat(path) - if err != nil { - if !os.IsNotExist(err) { - result.success(err.Error()) - } - result.success("") + if !isInit.Load() { + response.success("not initialized") return } - if fileInfo.IsDir() { - err = os.RemoveAll(path) - if err != nil { - result.success(err.Error()) - return - } - } else { - err = os.Remove(path) - if err != nil { - result.success(err.Error()) - 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 } - result.success("") + _ = os.Remove(providersRoot) + response.success("") }() } -func handleSetupConfig(bytes []byte) string { - if !isInit { +func handleSetupConfig(params *SetupParams) string { + if !isInit.Load() { return "not initialized" } - var params = defaultSetupParams() - err := UnmarshalJson(bytes, params) - if err != nil { - logError("unmarshalRawConfig error %v", err) - _ = applyConfig(defaultSetupParams()) - return err.Error() - } - err = applyConfig(params) + err := applyConfig(params) if err != nil { return err.Error() } diff --git a/core/lib.go b/core/lib.go index 61d0089e57..7082f44374 100644 --- a/core/lib.go +++ b/core/lib.go @@ -159,43 +159,44 @@ func handleUpdateDns(value string) { }() } -func (result ActionResult) send() { - data, err := result.Json() +func (response MethodResponse) send() { + data, err := response.JSON() if err != nil { return } - invokeResult(result.callback, string(data)) - if result.Method != messageMethod { - releaseObject(result.callback) - } + invokeResult(response.callback, string(data)) + releaseObject(response.callback) } -func nextHandle(action *Action, result ActionResult) bool { - switch action.Method { +func handlePlatformMethodCall(call *MethodCall, response MethodResponse) bool { + switch call.Method { case updateDnsMethod: - data := action.Data.(string) - handleUpdateDns(data) - result.success(true) + value := "" + if !decodeMethodArguments(call, response, &value) { + return true + } + handleUpdateDns(value) + response.success(true) return true } return false } -//export invokeAction -func invokeAction(callback unsafe.Pointer, paramsChar *C.char) { +//export invokeMethod +func invokeMethod(callback unsafe.Pointer, paramsChar *C.char) { params := takeCString(paramsChar) - var action = &Action{} - err := json.Unmarshal([]byte(params), action) + call := &MethodCall{} + err := json.Unmarshal([]byte(params), call) if err != nil { - invokeResult(callback, err.Error()) + response := MethodResponse{callback: callback} + response.failure("invalid_method_call", err.Error(), nil) return } - result := ActionResult{ - Id: action.Id, - Method: action.Method, + response := MethodResponse{ + ID: call.ID, callback: callback, } - go handleAction(action, result) + go handleMethodCall(call, response) } //export startTUN @@ -212,14 +213,21 @@ func startTUN(callback unsafe.Pointer, fd C.int, stackChar, addressChar, dnsChar //export quickSetup func quickSetup(callback unsafe.Pointer, initParamsChar *C.char, setupParamsChar *C.char) { go func() { + defer releaseObject(callback) initParamsString := takeCString(initParamsChar) setupParamsString := takeCString(setupParamsChar) - if !handleInitClash(initParamsString) { + initParams := InitParams{} + if err := json.Unmarshal([]byte(initParamsString), &initParams); err != nil || !handleInitClash(&initParams) { invokeResult(callback, "init failed") return } isRunning = true - message := handleSetupConfig([]byte(setupParamsString)) + setupParams := defaultSetupParams() + if err := UnmarshalJson([]byte(setupParamsString), setupParams); err != nil { + invokeResult(callback, err.Error()) + return + } + message := handleSetupConfig(setupParams) invokeResult(callback, message) }() } @@ -234,28 +242,42 @@ func setEventListener(listener unsafe.Pointer) { //export getTotalTraffic func getTotalTraffic(onlyStatisticsProxy bool) *C.char { - data := C.CString(handleGetTotalTraffic(onlyStatisticsProxy)) - defer C.free(unsafe.Pointer(data)) - return data + return C.CString(marshalResult(handleGetTotalTraffic(onlyStatisticsProxy))) } //export getTraffic func getTraffic(onlyStatisticsProxy bool) *C.char { - data := C.CString(handleGetTraffic(onlyStatisticsProxy)) - defer C.free(unsafe.Pointer(data)) - return data + return C.CString(marshalResult(handleGetTraffic(onlyStatisticsProxy))) } -func sendMessage(message Message) { +func marshalResult(value any) string { + data, err := json.Marshal(value) + if err != nil { + logError("Result marshal error: %v", err) + return "" + } + return string(data) +} + +func sendMessageBatch(messages []Message) { if eventListener == nil { return } - result := ActionResult{ - Method: messageMethod, - callback: eventListener, - Data: 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 } - result.send() + invokeResult(eventListener, string(data)) } //export stopTun diff --git a/core/message.go b/core/message.go new file mode 100644 index 0000000000..cb3dff0a54 --- /dev/null +++ b/core/message.go @@ -0,0 +1,124 @@ +package main + +import "time" + +const ( + messageBatchInterval = 16 * time.Millisecond + messageBatchSize = 32 + messageQueueSize = 256 + messagePriorityBurst = 8 +) + +var ( + priorityMessageQueue = make(chan Message, messageQueueSize) + bulkMessageQueue = make(chan Message, messageQueueSize) +) + +func init() { + go runMessageBatcher(priorityMessageQueue, bulkMessageQueue, sendMessageBatch) +} + +func sendMessage(message Message) { + queue := priorityMessageQueue + if message.Type == LogMessage || message.Type == RequestMessage { + queue = bulkMessageQueue + } + enqueueLatest(queue, message) +} + +func enqueueLatest(queue chan Message, message Message) { + select { + case queue <- message: + return + default: + } + + // 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: + default: + } + select { + case queue <- message: + default: + } +} + +func runMessageBatcher( + priorityMessages <-chan Message, + bulkMessages <-chan Message, + send func([]Message), +) { + ticker := time.NewTicker(messageBatchInterval) + defer ticker.Stop() + + batch := make([]Message, 0, messageBatchSize) + flush := func() { + if len(batch) == 0 { + return + } + current := append([]Message(nil), batch...) + batch = batch[:0] + send(current) + } + appendMessage := func(message Message) { + batch = append(batch, message) + if len(batch) >= messageBatchSize { + flush() + } + } + + priorityBurst := 0 + for priorityMessages != nil || bulkMessages != nil { + // Give bulk events one guaranteed opportunity after a bounded priority + // burst, while retaining priority preference under ordinary load. + if priorityBurst >= messagePriorityBurst && bulkMessages != nil { + select { + case message, ok := <-bulkMessages: + if !ok { + bulkMessages = nil + } else { + appendMessage(message) + } + priorityBurst = 0 + continue + default: + priorityBurst = 0 + } + } + + // Prefer state-bearing events whenever both queues have work. + select { + case message, ok := <-priorityMessages: + if !ok { + priorityMessages = nil + } else { + appendMessage(message) + priorityBurst++ + } + continue + default: + } + + select { + case message, ok := <-priorityMessages: + if !ok { + priorityMessages = nil + } else { + appendMessage(message) + priorityBurst++ + } + case message, ok := <-bulkMessages: + if !ok { + bulkMessages = nil + } else { + appendMessage(message) + } + priorityBurst = 0 + case <-ticker.C: + flush() + } + } + flush() +} diff --git a/core/method.go b/core/method.go new file mode 100644 index 0000000000..62c4c3abaf --- /dev/null +++ b/core/method.go @@ -0,0 +1,276 @@ +package main + +import ( + "encoding/json" + "fmt" + "runtime" + "unsafe" +) + +type MethodCall struct { + ID string `json:"id,omitempty"` + Method CoreMethod `json:"method"` + Arguments json.RawMessage `json:"arguments"` +} + +func (call MethodCall) decodeArguments(target any) error { + if len(call.Arguments) == 0 || string(call.Arguments) == "null" { + return fmt.Errorf("missing arguments") + } + return json.Unmarshal(call.Arguments, target) +} + +func decodeMethodArguments(call *MethodCall, response MethodResponse, target any) bool { + if err := call.decodeArguments(target); err != nil { + response.failure( + "invalid_arguments", + fmt.Sprintf("invalid arguments for %s: %v", call.Method, err), + nil, + ) + return false + } + return true +} + +type MethodError struct { + Code string `json:"code"` + Message string `json:"message"` + Details any `json:"details"` +} + +type MethodResponse struct { + ID string `json:"id,omitempty"` + Result any `json:"result"` + Error *MethodError `json:"error,omitempty"` + callback unsafe.Pointer +} + +func (response MethodResponse) JSON() ([]byte, error) { + return json.Marshal(response) +} + +func (response MethodResponse) success(result any) { + response.Result = result + response.Error = nil + response.send() +} + +func (response MethodResponse) failure(code, message string, details any) { + response.Result = nil + response.Error = &MethodError{ + Code: code, + Message: message, + Details: details, + } + response.send() +} + +func (response MethodResponse) notImplemented(method CoreMethod) { + response.failure( + "not_implemented", + fmt.Sprintf("unknown method: %s", method), + nil, + ) +} + +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) + } + }() + switch call.Method { + case initClashMethod: + params := InitParams{} + if !decodeMethodArguments(call, response, ¶ms) { + return + } + response.success(handleInitClash(¶ms)) + return + case getIsInitMethod: + response.success(handleGetIsInit()) + return + case forceGcMethod: + handleForceGC() + response.success(true) + return + case shutdownMethod: + 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 + } + response.success(handleSetupConfig(params)) + return + case getProxiesMethod: + response.success(handleGetProxies()) + return + case changeProxyMethod: + params := ChangeProxyParams{} + if !decodeMethodArguments(call, response, ¶ms) { + return + } + handleChangeProxy(¶ms, func(value string) { + response.success(value) + }) + 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: + handleResetTraffic() + response.success(true) + return + case asyncTestDelayMethod: + params := TestDelayParams{} + if !decodeMethodArguments(call, response, ¶ms) { + return + } + handleAsyncTestDelay(¶ms, func(value *Delay) { + response.success(value) + }) + return + case getConnectionsMethod: + response.success(handleGetConnections()) + return + case closeConnectionsMethod: + response.success(handleCloseConnections()) + return + case resetConnectionsMethod: + 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: + 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) + }) + 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) + }) + return + case startLogMethod: + handleStartLog() + response.success(true) + return + case stopLogMethod: + handleStopLog() + response.success(true) + return + case startListenerMethod: + response.success(handleStartListener()) + return + case stopListenerMethod: + response.success(handleStopListener()) + return + case getCountryCodeMethod: + ip := "" + if !decodeMethodArguments(call, response, &ip) { + return + } + handleGetCountryCode(ip, func(value string) { + response.success(value) + }) + return + case getMemoryMethod: + handleGetMemory(func(value uint64) { + response.success(value) + }) + return + case clearEffectMethod: + var profileId int64 + if !decodeMethodArguments(call, response, &profileId) { + return + } + handleClearEffect(profileId, response) + return + default: + if !handlePlatformMethodCall(call, response) { + response.notImplemented(call.Method) + } + } +} diff --git a/core/server.go b/core/server.go index 87f412eeee..28b8cb6135 100644 --- a/core/server.go +++ b/core/server.go @@ -5,6 +5,7 @@ package main import ( "encoding/binary" "encoding/json" + "fmt" "io" "sync" ) @@ -14,29 +15,59 @@ var ( connMu sync.Mutex ) -func (result ActionResult) send() { - data, err := result.Json() +const maxIPCFrameSize = 64 * 1024 * 1024 + +func (response MethodResponse) send() { + data, err := response.JSON() if err != nil { - logError("ActionResult marshal error: method=%s id=%s err=%v", result.Method, result.Id, err) + logError("MethodResponse marshal error: id=%s err=%v", response.ID, err) return } send(data) } -func sendMessage(message Message) { - result := ActionResult{ - Method: messageMethod, - Data: message, +func sendMessageBatch(messages []Message) { + arguments, err := json.Marshal(messages) + if err != nil { + logError("Message batch marshal error: %v", err) + return } - result.send() + 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 + } + send(data) } func writeFrame(w io.Writer, data []byte) error { - frame := make([]byte, 4+len(data)) - binary.LittleEndian.PutUint32(frame, uint32(len(data))) - copy(frame[4:], data) - _, err := w.Write(frame) - return err + if len(data) > maxIPCFrameSize { + return 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 + } + return writeAll(w, data) +} + +func writeAll(w io.Writer, data []byte) error { + for len(data) > 0 { + n, err := w.Write(data) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + data = data[n:] + } + return nil } func readFrame(r io.Reader) ([]byte, error) { @@ -45,7 +76,10 @@ func readFrame(r io.Reader) ([]byte, error) { return nil, err } length := binary.LittleEndian.Uint32(lenBuf) - data := make([]byte, length) + if length > maxIPCFrameSize { + return nil, fmt.Errorf("IPC frame exceeds %d bytes", maxIPCFrameSize) + } + data := make([]byte, int(length)) if _, err := io.ReadFull(r, data); err != nil { return nil, err } @@ -83,24 +117,23 @@ func startServer(arg string) { } return } - var action = &Action{} + call := &MethodCall{} - err = json.Unmarshal(data, action) + err = json.Unmarshal(data, call) if err != nil { logError("server unmarshal error: %v (data: %q)", err, data) continue } - result := ActionResult{ - Id: action.Id, - Method: action.Method, + response := MethodResponse{ + ID: call.ID, } - go handleAction(action, result) + go handleMethodCall(call, response) } } -func nextHandle(action *Action, result ActionResult) bool { +func handlePlatformMethodCall(call *MethodCall, response MethodResponse) bool { return false } diff --git a/lib/application.dart b/lib/application.dart index e1416c3060..ee46de24af 100644 --- a/lib/application.dart +++ b/lib/application.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:fl_clash/common/common.dart'; -import 'package:fl_clash/core/core.dart'; import 'package:fl_clash/l10n/l10n.dart'; import 'package:fl_clash/manager/hotkey_manager.dart'; import 'package:fl_clash/manager/manager.dart'; @@ -185,11 +184,9 @@ class ApplicationState extends ConsumerState { } @override - Future dispose() async { + void dispose() { linkManager.destroy(); _autoUpdateProfilesTaskTimer?.cancel(); - await coreController.destroy(); - await ref.read(systemActionProvider.notifier).handleExit(); super.dispose(); } } diff --git a/lib/common/color.dart b/lib/common/color.dart index a12c028fd0..4df12565b6 100644 --- a/lib/common/color.dart +++ b/lib/common/color.dart @@ -4,43 +4,43 @@ import 'package:flutter/material.dart'; extension ColorExtension on Color { Color get opacity80 { - return withAlpha(204); + return withValues(alpha: 0.8); } Color get opacity60 { - return withAlpha(153); + return withValues(alpha: 0.6); } Color get opacity50 { - return withAlpha(128); + return withValues(alpha: 0.5); } Color get opacity38 { - return withAlpha(97); + return withValues(alpha: 0.38); } Color get opacity30 { - return withAlpha(77); + return withValues(alpha: 0.3); } Color get opacity12 { - return withAlpha(31); + return withValues(alpha: 0.12); } Color get opacity15 { - return withAlpha(38); + return withValues(alpha: 0.15); } Color get opacity10 { - return withAlpha(15); + return withValues(alpha: 0.1); } Color get opacity3 { - return withAlpha(76); + return withValues(alpha: 0.03); } Color get opacity0 { - return withAlpha(0); + return withValues(alpha: 0); } int get value32bit { diff --git a/lib/common/common.dart b/lib/common/common.dart index 72fc85dc68..f606be9ad0 100644 --- a/lib/common/common.dart +++ b/lib/common/common.dart @@ -20,7 +20,6 @@ export 'launch.dart'; export 'link.dart'; export 'lock.dart'; export 'measure.dart'; -export 'migration.dart'; export 'mixin.dart'; export 'navigation.dart'; export 'navigator.dart'; diff --git a/lib/common/constant.dart b/lib/common/constant.dart index 813030bc47..70f5fb8ce9 100644 --- a/lib/common/constant.dart +++ b/lib/common/constant.dart @@ -16,8 +16,10 @@ const browserUa = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; const packageName = 'com.follow.clash'; final unixSocketPath = '/tmp/FlClashSocket_${Random().nextInt(10000)}.sock'; -final windowsPipeName = '\\\\.\\pipe\\FlClashCore_${Random().nextInt(10000)}'; +final windowsPipeName = '\\\\.\\pipe\\FlClashCore_${_randomPipeId()}'; const helperPort = 47890; +const helperProtocolVersionHeader = 'x-flclash-helper-protocol'; +const helperProtocolVersion = '5'; const maxTextScale = 1.4; const minTextScale = 0.8; final baseInfoEdgeInsets = EdgeInsets.symmetric( @@ -34,6 +36,14 @@ const sheetAppBarHeight = 68.0; const watchExecution = false; +String _randomPipeId() { + final random = Random.secure(); + return List.generate( + 16, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); +} + final defaultTextScaleFactor = WidgetsBinding.instance.platformDispatcher.textScaleFactor; const httpTimeoutDuration = Duration(milliseconds: 5000); diff --git a/lib/common/dav_client.dart b/lib/common/dav_client.dart index e4ea95a445..b60384d08c 100644 --- a/lib/common/dav_client.dart +++ b/lib/common/dav_client.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/models/models.dart'; +import 'package:flutter/foundation.dart'; import 'package:webdav_client/webdav_client.dart'; +typedef DAVClientFactory = DAVClient Function(DAVProps props); + class DAVClient { late Client client; late String fileName; @@ -43,3 +46,46 @@ class DAVClient { return true; } } + +class DAVConnectionController extends ValueNotifier { + DAVConnectionController({DAVClientFactory? createClient}) + : _createClient = createClient ?? DAVClient.new, + super(null); + + final DAVClientFactory _createClient; + + DAVProps? _lastProps; + bool _hasUpdated = false; + int _requestId = 0; + bool _disposed = false; + + DAVClient? client; + + Future update(DAVProps? props) async { + final nextClient = props == null ? null : _createClient(props); + client = nextClient; + + final rawProps = props?.copyWith(fileName: ''); + final rawLastProps = _lastProps?.copyWith(fileName: ''); + final isSameCredentials = _hasUpdated && rawProps == rawLastProps; + _lastProps = props; + _hasUpdated = true; + if (isSameCredentials) { + return; + } + + final requestId = ++_requestId; + value = null; + final result = await nextClient?.ping() ?? false; + if (!_disposed && requestId == _requestId) { + value = result; + } + } + + @override + void dispose() { + _disposed = true; + _requestId++; + super.dispose(); + } +} diff --git a/lib/common/function.dart b/lib/common/function.dart index e171152560..40ae36869a 100644 --- a/lib/common/function.dart +++ b/lib/common/function.dart @@ -28,6 +28,22 @@ class Debouncer { } } +class SerialTaskScheduler { + Future _serialTail = Future.value(); + + Future run(Future Function() task) { + final completer = Completer(); + _serialTail = _serialTail.then((_) async { + try { + completer.complete(await task()); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + return completer.future; + } +} + class Throttler { final Map _operations = {}; diff --git a/lib/common/future.dart b/lib/common/future.dart index 0f880dfab5..465ed65624 100644 --- a/lib/common/future.dart +++ b/lib/common/future.dart @@ -11,11 +11,19 @@ extension FutureExt on Future { FutureOr Function()? onTimeout, }) { final realTimeout = timeout ?? const Duration(minutes: 3); - Timer(realTimeout + commonDuration, () { - if (onLast != null) { - onLast(); - } - }); + final cleanupTimer = onLast == null + ? null + : Timer(realTimeout + commonDuration, onLast); + if (cleanupTimer != null) { + unawaited( + then( + (_) => cleanupTimer.cancel(), + onError: (Object _, StackTrace _) { + cleanupTimer.cancel(); + }, + ), + ); + } return this.timeout( realTimeout, onTimeout: () async { diff --git a/lib/common/migration.dart b/lib/common/migration.dart index 7d9646527c..a3329d5b25 100644 --- a/lib/common/migration.dart +++ b/lib/common/migration.dart @@ -1,53 +1,148 @@ -import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/database/database.dart'; import 'package:fl_clash/models/models.dart'; -class Migration { - static Migration? _instance; - late int _oldVersion; +import 'preferences.dart'; +import 'task.dart'; + +typedef MigrationTransform = + Future Function(Map configMap); + +abstract interface class MigrationStore { + Future?> getConfigMap(); + + Future getVersion(); - Migration._internal(); + Future?> getClashConfigMap(); - final currentVersion = 1; + Future restore(MigrationData data); + + Future saveConfig(Config config); + + Future clearClashConfig(); + + Future setVersion(int version); +} - factory Migration() { - _instance ??= Migration._internal(); - return _instance!; +class _AppMigrationStore implements MigrationStore { + const _AppMigrationStore(); + + @override + Future?> getConfigMap() => preferences.getConfigMap(); + + @override + Future getVersion() => preferences.getVersion(); + + @override + Future?> getClashConfigMap() => + preferences.getClashConfigMap(); + + @override + Future restore(MigrationData data) { + return database.restore( + data.profiles, + data.scripts, + data.rules, + data.links, + data.proxyGroups, + ); } - Future migrationIfNeeded( - Map? configMap, { - required Future Function(MigrationData data) sync, - }) async { - _oldVersion = await preferences.getVersion(); - if (_oldVersion == currentVersion) { + @override + Future saveConfig(Config config) => preferences.saveConfig(config); + + @override + Future clearClashConfig() => preferences.clearClashConfig(); + + @override + Future setVersion(int version) => preferences.setVersion(version); +} + +class Migration { + final MigrationStore _store; + final MigrationTransform _migrateV0; + + Migration({required MigrationStore store, MigrationTransform? migrateV0}) + : _store = store, + _migrateV0 = migrateV0 ?? oldToNowTask; + + static const currentVersion = 1; + + Future run() async { + final configMap = await _store.getConfigMap(); + var oldVersion = await _store.getVersion(); + Config? config; + if (oldVersion > currentVersion) { + throw StateError( + 'Local data version $oldVersion is newer than $currentVersion.', + ); + } + if (oldVersion == currentVersion) { try { - return Config.realFromJson(configMap); + config = Config.realFromJson(configMap); } catch (_) { - final isV0 = configMap?['proxiesStyle'] != null; - if (isV0) { - _oldVersion = 0; - } else { - throw 'Local data is damaged. A reset is required to fix this issue.'; + if (!_isV0(configMap)) { + throw StateError( + 'Local data is damaged. A reset is required to fix this issue.', + ); + } + oldVersion = 0; + } + if (config != null) { + final storedDavPassword = _getStoredDavPassword(configMap); + final hasPlainTextDavPassword = + storedDavPassword != null && + storedDavPassword == config.davProps?.password; + if (hasPlainTextDavPassword && !await _store.saveConfig(config)) { + throw StateError('Failed to obfuscate the legacy WebDAV password'); } + return config; } } + MigrationData data = MigrationData(configMap: configMap); - if (_oldVersion == 0 && configMap != null) { - final clashConfigMap = await preferences.getClashConfigMap(); - if (clashConfigMap != null) { - configMap['patchClashConfig'] = clashConfigMap; - await preferences.clearClashConfig(); + var shouldClearClashConfig = false; + if (oldVersion == 0) { + final clashConfigMap = await _store.getClashConfigMap(); + if (_isV0(configMap) && configMap != null) { + final legacyConfigMap = Map.from(configMap); + if (clashConfigMap != null) { + legacyConfigMap['patchClashConfig'] = clashConfigMap; + shouldClearClashConfig = true; + } + data = await _migrateV0(legacyConfigMap); + } else if (clashConfigMap != null) { + final currentConfigMap = Map.from( + configMap ?? const {}, + ); + currentConfigMap.putIfAbsent('patchClashConfig', () => clashConfigMap); + data = MigrationData(configMap: currentConfigMap); + shouldClearClashConfig = true; } - data = await _oldToNow(configMap); } - final res = await sync(data); - await preferences.setVersion(currentVersion); - return res; + + config = Config.realFromJson(data.configMap); + await _store.restore(data); + if (!await _store.saveConfig(config)) { + throw StateError('Failed to save migrated preferences'); + } + if (shouldClearClashConfig) { + await _store.clearClashConfig(); + } + await _store.setVersion(currentVersion); + return config; } +} + +bool _isV0(Map? configMap) => + configMap?['proxiesStyle'] != null; - Future _oldToNow(Map configMap) async { - return oldToNowTask(configMap); +String? _getStoredDavPassword(Map? configMap) { + final dav = configMap?['davProps'] ?? configMap?['dav']; + if (dav is! Map) { + return null; } + final password = dav['password']; + return password is String && password.isNotEmpty ? password : null; } -final migration = Migration(); +final migration = Migration(store: const _AppMigrationStore()); diff --git a/lib/common/path.dart b/lib/common/path.dart index ed20647cb3..059db565f3 100644 --- a/lib/common/path.dart +++ b/lib/common/path.dart @@ -8,7 +8,7 @@ import 'package:path_provider/path_provider.dart'; class AppPath { static AppPath? _instance; Completer dataDir = Completer(); - Completer downloadDir = Completer(); + late final Future _downloadDir = getDownloadsDirectory(); Completer tempDir = Completer(); Completer cacheDir = Completer(); late String appDirPath; @@ -21,9 +21,6 @@ class AppPath { getTemporaryDirectory().then((value) { tempDir.complete(value); }); - getDownloadsDirectory().then((value) { - downloadDir.complete(value); - }); getApplicationCacheDirectory().then((value) { cacheDir.complete(value); }); @@ -52,8 +49,8 @@ class AppPath { } Future get downloadDirPath async { - final directory = await downloadDir.future; - return directory.path; + final directory = await _downloadDir; + return directory?.path ?? await homeDirPath; } Future get homeDirPath async { diff --git a/lib/common/permission.dart b/lib/common/permission.dart index b5d78a6db1..7ce9bf73dd 100644 --- a/lib/common/permission.dart +++ b/lib/common/permission.dart @@ -5,19 +5,45 @@ import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/plugins/app.dart'; import 'package:fl_clash/providers/providers.dart'; import 'package:fl_clash/state.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wifi_ssid/wifi_ssid_manager.dart'; +enum LocationPermissionFollowUp { none, showDeniedMessage, openSettings } + +LocationPermissionFollowUp getLocationPermissionFollowUp( + WifiSsidPermission permission, +) { + return switch (permission) { + WifiSsidPermission.granted => LocationPermissionFollowUp.none, + WifiSsidPermission.denied => LocationPermissionFollowUp.showDeniedMessage, + WifiSsidPermission.permanentlyDenied => + LocationPermissionFollowUp.openSettings, + }; +} + class Permissions { static Permissions? _instance; - Permissions._internal(); + Permissions._internal({bool Function()? supportsLocationPermissions}) + : _supportsLocationPermissions = + supportsLocationPermissions ?? + (() => system.isAndroid || system.isMacOS); factory Permissions() { _instance ??= Permissions._internal(); return _instance!; } + @visibleForTesting + factory Permissions.test({required bool supportsLocationPermissions}) { + return Permissions._internal( + supportsLocationPermissions: () => supportsLocationPermissions, + ); + } + + final bool Function() _supportsLocationPermissions; + bool _isRequestingLocation = false; bool needWaitingBatteryOptimizationSettings = false; @@ -53,7 +79,7 @@ class Permissions { } Future checkLocationPermissions() async { - if (!(system.isAndroid || system.isMacOS)) { + if (!_supportsLocationPermissions()) { return; } final res = await WifiSsidManager.instance.checkPermission(); @@ -74,7 +100,7 @@ class Permissions { final res = await WifiSsidManager.instance.requestPermission(); globalState.container.read(locationPermissionsProvider.notifier).value = res; - if (res != WifiSsidPermission.granted) { + if (res == WifiSsidPermission.granted) { final ssid = await WifiSsidManager.instance.getSsid(); globalState.container.read(currentSSIDProvider.notifier).value = ssid; } diff --git a/lib/common/request.dart b/lib/common/request.dart index 1bbe9f3cdc..206548ec6d 100644 --- a/lib/common/request.dart +++ b/lib/common/request.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; @@ -9,7 +9,6 @@ import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/state.dart'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; class Request { late final Dio dio; @@ -143,61 +142,6 @@ class Request { token.cancel(); return res; } - - Future pingHelper() async { - if (kDebugMode) return true; - try { - final response = await dio - .get( - 'http://$localhost:$helperPort/ping', - options: Options(responseType: ResponseType.plain), - ) - .timeout(const Duration(milliseconds: 2000)); - if (response.statusCode != HttpStatus.ok) { - return false; - } - return (response.data as String) == globalState.coreSHA256; - } catch (_) { - return false; - } - } - - Future startCoreByHelper(String arg) async { - try { - final response = await dio - .post( - 'http://$localhost:$helperPort/start', - data: json.encode({'path': appPath.corePath, 'arg': arg}), - options: Options(responseType: ResponseType.plain), - ) - .timeout(const Duration(milliseconds: 2000)); - if (response.statusCode != HttpStatus.ok) { - return false; - } - final data = response.data as String; - return data.isEmpty; - } catch (_) { - return false; - } - } - - Future stopCoreByHelper() async { - try { - final response = await dio - .post( - 'http://$localhost:$helperPort/stop', - options: Options(responseType: ResponseType.plain), - ) - .timeout(const Duration(milliseconds: 2000)); - if (response.statusCode != HttpStatus.ok) { - return false; - } - final data = response.data as String; - return data.isEmpty; - } catch (_) { - return false; - } - } } final request = Request(); diff --git a/lib/common/system.dart b/lib/common/system.dart index f86178b597..2d52e539d7 100644 --- a/lib/common/system.dart +++ b/lib/common/system.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:ffi/ffi.dart'; import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/core/desktop/helper_client.dart'; import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/plugins/app.dart'; import 'package:fl_clash/state.dart'; @@ -41,11 +42,15 @@ class System { }; } + Future didCrashOnPreviousExecution() async { + if (!isAndroid) return false; + return await app?.didCrashOnPreviousExecution() ?? false; + } + Future checkIsAdmin() async { final corePath = appPath.corePath.replaceAll(' ', '\\\\ '); if (system.isWindows) { - final result = await windows?.checkService(); - return result == WindowsHelperServiceStatus.running; + return windowsHelperClient.isReady(); } else if (system.isMacOS) { final result = await Process.run('stat', ['-f', '%Su:%Sg %Sp', corePath]); final output = result.stdout.trim(); @@ -72,19 +77,14 @@ class System { if (system.isAndroid) { return AuthorizeCode.error; } + if (system.isWindows) { + return await windows?.registerService() ?? AuthorizeCode.error; + } final isAdmin = await checkIsAdmin(); if (isAdmin) { return AuthorizeCode.none; } - if (system.isWindows) { - final result = await windows?.registerService(); - if (result == true) { - return AuthorizeCode.success; - } - return AuthorizeCode.error; - } - if (system.isMacOS) { final escapedPath = _shellEscape(appPath.corePath); final shell = 'chown root:admin $escapedPath && chmod +sx $escapedPath'; @@ -134,7 +134,6 @@ class System { if (system.isAndroid) { await SystemNavigator.pop(); } - await window?.close(); window?.forceExit(); } } @@ -203,79 +202,52 @@ class Windows { return true; } - // Future _killProcess(int port) async { - // final result = await Process.run('netstat', ['-ano']); - // final lines = result.stdout.toString().trim().split('\n'); - // for (final line in lines) { - // if (!line.contains(':$port') || !line.contains('LISTENING')) { - // continue; - // } - // final parts = line.trim().split(RegExp(r'\s+')); - // final pid = int.tryParse(parts.last); - // if (pid != null) { - // await Process.run('taskkill', ['/PID', pid.toString(), '/F']); - // } - // } - // } - - Future checkService() async { - // final qcResult = await Process.run('sc', ['qc', appHelperService]); - // final qcOutput = qcResult.stdout.toString(); - // if (qcResult.exitCode != 0 || !qcOutput.contains(appPath.helperPath)) { - // return WindowsHelperServiceStatus.none; - // } - final result = await Process.run('sc', ['query', appHelperService]); - if (result.exitCode != 0) { - return WindowsHelperServiceStatus.none; - } - final output = result.stdout.toString(); - if (output.contains('RUNNING') && await request.pingHelper()) { - return WindowsHelperServiceStatus.running; + Future registerService() async { + if (await windowsHelperClient.isReady()) { + commonPrint.log('helper service is ready'); + return AuthorizeCode.none; } - return WindowsHelperServiceStatus.presence; - } - - Future registerService() async { - final status = await checkService(); - if (status == WindowsHelperServiceStatus.running) { - return true; + commonPrint.log( + 'helper service is unavailable, requesting elevated installation', + logLevel: LogLevel.warning, + ); + if (!runas(appPath.helperPath, 'install')) { + commonPrint.log( + 'failed to launch elevated helper installation', + logLevel: LogLevel.error, + ); + return AuthorizeCode.error; } - final command = [ - '/c', - if (status == WindowsHelperServiceStatus.presence) ...[ - 'taskkill', - '/F', - '/IM', - '$appHelperService.exe' - ' & ' - 'sc', - 'delete', - appHelperService, - '&', - ], - 'sc', - 'create', - appHelperService, - 'binPath= "${appPath.helperPath}"', - 'start= auto', - '&&', - 'sc', - 'start', - appHelperService, - ].join(' '); - - final res = runas('cmd.exe', command); - - await Future.delayed(const Duration(milliseconds: 300)); - final retryStatus = await retry( - task: checkService, - maxAttempts: 5, - retryIf: (status) => status != WindowsHelperServiceStatus.running, - delay: const Duration(seconds: 1), + final isRunning = await _waitForHelperService(); + commonPrint.log( + isRunning + ? 'helper service installation completed' + : 'helper service did not become ready after installation', + logLevel: isRunning ? LogLevel.info : LogLevel.error, ); - return res && retryStatus == WindowsHelperServiceStatus.running; + return isRunning ? AuthorizeCode.success : AuthorizeCode.error; + } + + Future _waitForHelperService() async { + const timeout = Duration(seconds: 6); + const interval = Duration(seconds: 1); + const maxAttempts = 6; + final stopwatch = Stopwatch()..start(); + for (var attempt = 0; attempt < maxAttempts; attempt++) { + final remaining = timeout - stopwatch.elapsed; + if (remaining <= Duration.zero) return false; + final isRunning = await windowsHelperClient.isReady( + timeout: remaining, + logFailure: false, + ); + if (isRunning) return true; + final delay = timeout - stopwatch.elapsed; + if (delay <= Duration.zero || attempt == maxAttempts - 1) return false; + await Future.delayed(delay < interval ? delay : interval); + } + return false; } Future registerTask(String appName) async { diff --git a/lib/common/tray.dart b/lib/common/tray.dart index 97e5362779..ab10ee3319 100644 --- a/lib/common/tray.dart +++ b/lib/common/tray.dart @@ -86,7 +86,7 @@ class Tray { final startMenuItem = MenuItem.checkbox( label: trayState.isStart ? appLocalizations.stop : appLocalizations.start, onClick: (_) async { - commonAction.updateStart(); + commonAction.toggleRunning(); }, checked: false, ); diff --git a/lib/common/utils.dart b/lib/common/utils.dart index c95dcc64fc..32a8664dc7 100644 --- a/lib/common/utils.dart +++ b/lib/common/utils.dart @@ -91,13 +91,14 @@ class Utils { } final diff = timeStamp / 1000; final inHours = (diff / 3600).floor(); - if (inHours > 99) { - return '99:59:59'; + if (inHours > 999) { + return '999:59:59'; } final inMinutes = (diff / 60 % 60).floor(); final inSeconds = (diff % 60).floor(); + final hoursText = inHours.toString().padLeft(2, '0'); - return '${getDateStringLast2(inHours)}:${getDateStringLast2(inMinutes)}:${getDateStringLast2(inSeconds)}'; + return '$hoursText:${getDateStringLast2(inMinutes)}:${getDateStringLast2(inSeconds)}'; } Locale? getLocaleForString(String? localString) { diff --git a/lib/core/controller.dart b/lib/core/controller.dart index c6b3bd545b..9e075e1dc6 100644 --- a/lib/core/controller.dart +++ b/lib/core/controller.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:fl_clash/common/common.dart'; @@ -36,11 +35,13 @@ class CoreController { return _instance!; } - bool get isCompleted => _interface.completer.isCompleted; + Future start() => _interface.start(); - Future preload() { - return _interface.preload(); - } + Future restart() => _interface.restart(); + + Future stop() => _interface.stop(); + + Future close() => _interface.close(); static Future initGeo() async { final homePath = await appPath.homeDirPath; @@ -76,10 +77,6 @@ class CoreController { return _interface.init(InitParams(homeDir: homeDirPath, version: version)); } - Future shutdown(bool isUser) async { - await _interface.shutdown(isUser); - } - FutureOr get isInit => _interface.isInit; Future validateConfig(String path) async { @@ -102,14 +99,16 @@ class CoreController { Future setupConfig({ required SetupParams params, - required SetupState setupState, - VoidCallback? preloadInvoke, + Future Function()? preloadInvoke, }) async { - final res = _interface.setupConfig(params); - if (preloadInvoke != null) { - preloadInvoke(); + if (preloadInvoke == null) { + return _interface.setupConfig(params); } - return res; + final (result, _) = await ( + _interface.setupConfig(params), + preloadInvoke(), + ).wait; + return result; } Future> getProxiesGroups({ @@ -135,10 +134,7 @@ class CoreController { } Future> getConnections() async { - final res = await _interface.getConnections(); - final connectionsData = json.decode(res) as Map; - final connectionsRaw = connectionsData['connections'] as List? ?? []; - return connectionsRaw.map((e) => TrackerInfo.fromJson(e)).toList(); + return _interface.getConnections(); } Future closeConnection(String id) async { @@ -154,27 +150,13 @@ class CoreController { } Future> getExternalProviders() async { - final externalProvidersRawString = await _interface.getExternalProviders(); - if (externalProvidersRawString.isEmpty) { - return []; - } - final externalProviders = - (await externalProvidersRawString.commonToJSON>()) - .map((item) => ExternalProvider.fromJson(item)) - .toList(); - return externalProviders; + return _interface.getExternalProviders(); } Future getExternalProvider( String externalProviderName, ) async { - final externalProvidersRawString = await _interface.getExternalProvider( - externalProviderName, - ); - if (externalProvidersRawString.isEmpty) { - return null; - } - return ExternalProvider.fromJson(json.decode(externalProvidersRawString)); + return _interface.getExternalProvider(externalProviderName); } Future updateGeoData(String type) { @@ -204,29 +186,21 @@ class CoreController { } Future getDelay(String url, String proxyName) async { - final data = await _interface.asyncTestDelay(url, proxyName); - return Delay.fromJson(json.decode(data)); + return _interface.asyncTestDelay(url, proxyName); } Future> getConfig(int id) async { final profilePath = await appPath.getProfilePath(id.toString()); - final res = await _interface.getConfig(profilePath); - if (res.isSuccess) { - final data = Map.from(res.data); - data['rules'] = data['rule']; - data.remove('rule'); - return data; - } else { - throw res.message; - } + final data = Map.from( + await _interface.getConfig(profilePath), + ); + data['rules'] = data['rule']; + data.remove('rule'); + return data; } Future getTraffic(bool onlyStatisticsProxy) async { - final trafficString = await _interface.getTraffic(onlyStatisticsProxy); - if (trafficString.isEmpty) { - return const Traffic(); - } - return Traffic.fromJson(json.decode(trafficString)); + return _interface.getTraffic(onlyStatisticsProxy); } Future getCountryCode(String ip) async { @@ -238,21 +212,11 @@ class CoreController { } Future getTotalTraffic(bool onlyStatisticsProxy) async { - final totalTrafficString = await _interface.getTotalTraffic( - onlyStatisticsProxy, - ); - if (totalTrafficString.isEmpty) { - return const Traffic(); - } - return Traffic.fromJson(json.decode(totalTrafficString)); + return _interface.getTotalTraffic(onlyStatisticsProxy); } Future getMemory() async { - final value = await _interface.getMemory(); - if (value.isEmpty) { - return 0; - } - return int.parse(value); + return _interface.getMemory(); } void resetTraffic() { @@ -271,16 +235,12 @@ class CoreController { await _interface.forceGc(); } - Future destroy() async { - await _interface.destroy(); - } - Future crash() async { await _interface.crash(); } - Future deleteFile(String path) async { - return _interface.deleteFile(path); + Future clearEffect(int profileId) async { + return _interface.clearEffect(profileId); } } diff --git a/lib/core/core.dart b/lib/core/core.dart index 92b5283f8d..c53def1f7e 100644 --- a/lib/core/core.dart +++ b/lib/core/core.dart @@ -1,5 +1,7 @@ export 'controller.dart'; export 'core.dart'; +export 'desktop/model.dart'; export 'event.dart'; export 'lib.dart'; +export 'method.dart'; export 'service.dart'; diff --git a/lib/core/desktop/helper_client.dart b/lib/core/desktop/helper_client.dart new file mode 100644 index 0000000000..d1ae6e9dd8 --- /dev/null +++ b/lib/core/desktop/helper_client.dart @@ -0,0 +1,385 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:fl_clash/common/constant.dart'; +import 'package:fl_clash/common/print.dart'; +import 'package:fl_clash/enum/enum.dart'; +import 'package:path/path.dart' as p; + +import 'launcher.dart'; +import 'model.dart'; + +final class HelperStartResponse { + final String sessionId; + final int pid; + + const HelperStartResponse({required this.sessionId, required this.pid}); +} + +final class HelperStopResponse { + final String sessionId; + final bool stopped; + final String? reason; + + const HelperStopResponse({ + required this.sessionId, + required this.stopped, + this.reason, + }); +} + +final class WindowsHelperException implements Exception { + final String code; + final String message; + final Object? details; + + const WindowsHelperException({ + required this.code, + required this.message, + this.details, + }); + + @override + String toString() => 'WindowsHelperException($code, $message, $details)'; +} + +final class WindowsHelperClient { + final Dio _dio; + final String Function() _expectedHelperPath; + final String baseUrl; + + WindowsHelperClient({ + Dio? dio, + String Function()? expectedHelperPath, + this.baseUrl = 'http://$localhost:$helperPort', + }) : _dio = dio ?? Dio(), + _expectedHelperPath = expectedHelperPath ?? _defaultHelperPath; + + static String _defaultHelperPath() { + final context = p.Context(style: p.Style.windows); + return context.join( + context.dirname(Platform.resolvedExecutable), + '$appHelperService.exe', + ); + } + + Future isReady({Duration? timeout, bool logFailure = true}) async { + if (timeout != null && timeout <= Duration.zero) { + return false; + } + final cancelToken = CancelToken(); + final timeoutTimer = timeout == null + ? null + : Timer( + timeout, + () => cancelToken.cancel('helper ping deadline exceeded'), + ); + try { + final response = await _dio.get( + '$baseUrl/ping', + cancelToken: cancelToken, + options: _options(ResponseType.plain), + ); + final helperPath = response.data; + if (response.statusCode != HttpStatus.ok || helperPath is! String) { + _logPingFailure('helper ping returned invalid response', logFailure); + return false; + } + final protocolVersion = response.headers.value( + helperProtocolVersionHeader, + ); + if (protocolVersion != helperProtocolVersion) { + _logPingFailure( + 'helper protocol mismatch: $protocolVersion', + logFailure, + ); + return false; + } + final matches = p.Context( + style: p.Style.windows, + ).equals(helperPath.trim(), _expectedHelperPath()); + if (!matches) { + _logPingFailure('helper executable path mismatch', logFailure); + } + return matches; + } catch (error) { + _logPingFailure('helper ping failed: $error', logFailure); + return false; + } finally { + timeoutTimer?.cancel(); + } + } + + void _logPingFailure(String message, bool enabled) { + if (enabled) { + commonPrint.log(message, logLevel: LogLevel.warning); + } + } + + Future start({ + required String address, + required String sessionId, + }) async { + _validateSessionId(sessionId); + try { + final response = await _dio.post( + '$baseUrl/start', + data: {'address': address, 'sessionId': sessionId}, + options: _options(ResponseType.json), + ); + final data = _responseMap(response, operation: 'start'); + final returnedSession = data['sessionId']; + final pid = data['pid']; + if (returnedSession != sessionId || pid is! int || pid <= 0) { + throw const WindowsHelperException( + code: 'invalidResponse', + message: 'Helper returned an invalid start response', + ); + } + return HelperStartResponse( + sessionId: returnedSession as String, + pid: pid, + ); + } on WindowsHelperException { + rethrow; + } on DioException catch (error) { + throw _mapDioException(error, operation: 'start'); + } catch (error) { + throw WindowsHelperException( + code: 'transportError', + message: 'Unable to start Core through Helper', + details: error.toString(), + ); + } + } + + Future stop(String sessionId) async { + _validateSessionId(sessionId); + try { + final response = await _dio.post( + '$baseUrl/stop', + data: {'sessionId': sessionId}, + options: _options(ResponseType.json), + ); + return _parseStopResponse(response, sessionId); + } on WindowsHelperException { + rethrow; + } on DioException catch (error) { + final response = error.response; + if (response?.statusCode == HttpStatus.conflict) { + final data = _mapFrom(response?.data); + final reason = data?['reason']; + if (reason is String) { + throw WindowsHelperException( + code: reason, + message: 'Helper refused to stop the requested Core session', + details: data, + ); + } + } + throw _mapDioException(error, operation: 'stop'); + } catch (error) { + throw WindowsHelperException( + code: 'transportError', + message: 'Unable to stop Core through Helper', + details: error.toString(), + ); + } + } + + HelperStopResponse _parseStopResponse( + Response response, + String sessionId, + ) { + final data = _responseMap(response, operation: 'stop'); + final returnedSession = data['sessionId']; + final stopped = data['stopped']; + final reason = data['reason']; + if (returnedSession != sessionId || + stopped is! bool || + (stopped && reason != null) || + (!stopped && reason != 'notRunning')) { + throw const WindowsHelperException( + code: 'invalidResponse', + message: 'Helper returned an invalid stop response', + ); + } + return HelperStopResponse( + sessionId: returnedSession as String, + stopped: stopped, + reason: reason as String?, + ); + } + + Map _responseMap( + Response response, { + required String operation, + }) { + if (response.statusCode != HttpStatus.ok) { + throw WindowsHelperException( + code: 'unexpectedStatus', + message: 'Helper $operation returned HTTP ${response.statusCode}', + details: response.data, + ); + } + final data = _mapFrom(response.data); + if (data == null) { + throw WindowsHelperException( + code: 'invalidResponse', + message: 'Helper returned an invalid $operation response', + details: response.data, + ); + } + return data; + } + + WindowsHelperException _mapDioException( + DioException error, { + required String operation, + }) { + final data = _mapFrom(error.response?.data); + final code = data?['code']; + final message = data?['message']; + if (code is String && message is String) { + return WindowsHelperException( + code: code, + message: message, + details: data?['details'], + ); + } + return WindowsHelperException( + code: 'transportError', + message: 'Helper $operation request failed', + details: error.toString(), + ); + } + + Map? _mapFrom(Object? data) { + if (data is! Map) { + return null; + } + return Map.from(data); + } + + void _validateSessionId(String sessionId) { + if (!RegExp(r'^[0-9a-f]{32}$').hasMatch(sessionId)) { + throw const WindowsHelperException( + code: 'invalidSessionId', + message: 'Core session ID must be 128-bit lowercase hexadecimal', + ); + } + } + + Options _options(ResponseType responseType) { + return Options( + responseType: responseType, + connectTimeout: const Duration(milliseconds: 300), + receiveTimeout: const Duration(seconds: 2), + ); + } +} + +final class WindowsHelperLauncher implements CoreProcessLauncher { + final WindowsHelperClient client; + + const WindowsHelperLauncher(this.client); + + @override + CoreProcessOwner get owner => CoreProcessOwner.windowsHelper; + + @override + Future start({ + required String sessionId, + required String address, + }) async { + try { + final response = await client.start( + address: address, + sessionId: sessionId, + ); + return HelperCoreLease( + sessionId: response.sessionId, + pid: response.pid, + client: client, + ); + } catch (error, stackTrace) { + try { + await client.stop(sessionId); + } catch (_) {} + Error.throwWithStackTrace(error, stackTrace); + } + } +} + +typedef HelperReadinessProbe = Future Function(); + +final class WindowsHelperLauncherResolver + implements DesktopCoreLauncherResolver { + final bool isWindows; + final CoreProcessLauncher directLauncher; + final CoreProcessLauncher helperLauncher; + final HelperReadinessProbe helperReady; + + const WindowsHelperLauncherResolver({ + required this.isWindows, + required this.directLauncher, + required this.helperLauncher, + required this.helperReady, + }); + + @override + Future resolve() async { + if (isWindows && await helperReady()) { + return helperLauncher; + } + return directLauncher; + } +} + +final class HelperCoreLease implements CoreProcessLease { + @override + final String sessionId; + + @override + final int pid; + + final WindowsHelperClient _client; + Future? _stopOperation; + + HelperCoreLease({ + required this.sessionId, + required this.pid, + required WindowsHelperClient client, + }) : _client = client; + + @override + CoreProcessOwner get owner => CoreProcessOwner.windowsHelper; + + @override + Future stop(Duration timeout) { + final stopOperation = _stopOperation; + if (stopOperation != null) { + return stopOperation; + } + final nextOperation = _stop().onError(( + Object error, + StackTrace stackTrace, + ) { + _stopOperation = null; + Error.throwWithStackTrace(error, stackTrace); + }); + _stopOperation = nextOperation; + return nextOperation; + } + + Future _stop() async { + final response = await _client.stop(sessionId); + return CoreProcessStopResult( + stopped: response.stopped, + exitConfirmed: true, + ); + } +} + +final windowsHelperClient = WindowsHelperClient(); diff --git a/lib/core/desktop/launcher.dart b/lib/core/desktop/launcher.dart new file mode 100644 index 0000000000..a2790e65bc --- /dev/null +++ b/lib/core/desktop/launcher.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/enum/enum.dart'; + +import 'model.dart'; + +typedef CoreProcessStarter = + Future Function(String executable, List arguments); + +abstract interface class CoreProcessLauncher { + CoreProcessOwner get owner; + + Future start({ + required String sessionId, + required String address, + }); +} + +abstract interface class DesktopCoreLauncherResolver { + Future resolve(); +} + +final class DirectCoreLauncher implements CoreProcessLauncher { + final CoreProcessStarter _startProcess; + final String corePath; + + DirectCoreLauncher({CoreProcessStarter? startProcess, String? corePath}) + : _startProcess = startProcess ?? Process.start, + corePath = corePath ?? appPath.corePath; + + @override + CoreProcessOwner get owner => CoreProcessOwner.direct; + + @override + Future start({ + required String sessionId, + required String address, + }) async { + final process = await _startProcess(corePath, [address]); + process.stdout.listen((_) {}); + process.stderr.listen((data) { + final error = utf8.decode(data); + if (error.isNotEmpty) { + commonPrint.log(error, logLevel: LogLevel.warning); + } + }); + return DirectCoreLease(sessionId: sessionId, process: process); + } +} + +final class DirectCoreLease implements CoreProcessLease { + @override + final String sessionId; + + final Process _process; + Future? _stopOperation; + + DirectCoreLease({required this.sessionId, required Process process}) + : _process = process; + + @override + CoreProcessOwner get owner => CoreProcessOwner.direct; + + @override + int get pid => _process.pid; + + @override + Future stop(Duration timeout) { + final stopOperation = _stopOperation; + if (stopOperation != null) { + return stopOperation; + } + final nextOperation = _stop(timeout).then((result) { + if (!result.exitConfirmed) { + _stopOperation = null; + } + return result; + }); + _stopOperation = nextOperation; + return nextOperation; + } + + Future _stop(Duration timeout) async { + final stopped = _process.kill(); + try { + await _process.exitCode.timeout(timeout); + return CoreProcessStopResult(stopped: stopped, exitConfirmed: true); + } on TimeoutException { + return CoreProcessStopResult(stopped: stopped, exitConfirmed: false); + } + } +} diff --git a/lib/core/desktop/lifecycle.dart b/lib/core/desktop/lifecycle.dart new file mode 100644 index 0000000000..d073c7b9c9 --- /dev/null +++ b/lib/core/desktop/lifecycle.dart @@ -0,0 +1,798 @@ +import 'dart:async'; + +import 'launcher.dart'; +import 'model.dart'; +import 'transport.dart'; + +abstract interface class DesktopCoreLifecycleController { + DesktopCoreState get state; + + Stream get states; + + Stream get crashEvents; + + Future start(); + + Future restart(); + + Future stop(); + + Future close(); + + Future waitUntilRunning(Duration timeout); +} + +enum _LifecycleTarget { running, restarted, stopped, closed } + +enum _LifecycleAchievement { runningExisting, runningFresh, idle, closed } + +final class _LifecycleIntent { + final int revision; + final _LifecycleTarget target; + + const _LifecycleIntent(this.revision, this.target); +} + +final class _PendingLifecycleCommand { + final _LifecycleIntent intent; + final Completer completer = + Completer(); + + _PendingLifecycleCommand(this.intent); +} + +final class _TransportConnectionWaiter { + final Completer _settled = Completer(); + late final StreamSubscription _subscription; + late final Timer _timer; + TransportConnected? _connection; + Object? _error; + StackTrace? _stackTrace; + + _TransportConnectionWaiter( + Stream events, + Duration timeout, + ) { + _subscription = events.listen( + (event) { + switch (event) { + case TransportConnected(): + if (!_settled.isCompleted) { + _connection = event; + _settled.complete(); + } + case TransportFailed(:final error, :final stackTrace): + if (!_settled.isCompleted) { + _error = error; + _stackTrace = stackTrace; + _settled.complete(); + } + case TransportReady() || TransportDisconnected(): + break; + } + }, + onDone: () { + if (!_settled.isCompleted) { + _error = StateError('Core transport closed before connection'); + _stackTrace = StackTrace.current; + _settled.complete(); + } + }, + ); + _timer = Timer(timeout, () { + if (!_settled.isCompleted) { + _error = TimeoutException( + 'Core transport connection timed out', + timeout, + ); + _stackTrace = StackTrace.current; + _settled.complete(); + } + }); + } + + Future get future async { + await _settled.future; + final error = _error; + if (error != null) { + Error.throwWithStackTrace(error, _stackTrace ?? StackTrace.current); + } + return _connection!; + } + + Future cancel() async { + _timer.cancel(); + await _subscription.cancel(); + } +} + +final class DesktopCoreLifecycle implements DesktopCoreLifecycleController { + final DesktopCoreTransport Function() transportFactory; + final DesktopCoreLauncherResolver launcherResolver; + final DesktopCoreTimeouts timeouts; + final String Function() sessionIdFactory; + final bool verifyPeerPid; + final DesktopCoreTransportBinding _transport; + + final StreamController _stateController = + StreamController.broadcast(); + final StreamController _crashController = + StreamController.broadcast(); + final List<_PendingLifecycleCommand> _pending = []; + + late final StreamSubscription _transportSubscription; + DesktopCoreState _state = const DesktopCoreIdle(); + DesktopCoreSession? _session; + Future? _worker; + Future? _unexpectedDisconnectOperation; + CoreProcessLease? _unconfirmedLease; + Future? _closeResult; + int _revision = 0; + _LifecycleIntent _desired = const _LifecycleIntent( + 0, + _LifecycleTarget.stopped, + ); + Completer _intentChanged = Completer(); + bool _terminalRequested = false; + + factory DesktopCoreLifecycle({ + required DesktopCoreTransport Function() transportFactory, + required DesktopCoreLauncherResolver launcherResolver, + DesktopCoreTimeouts timeouts = const DesktopCoreTimeouts(), + String Function()? sessionIdFactory, + bool verifyPeerPid = false, + }) { + return DesktopCoreLifecycle._( + transportFactory: transportFactory, + launcherResolver: launcherResolver, + timeouts: timeouts, + sessionIdFactory: sessionIdFactory ?? createCoreSessionId, + verifyPeerPid: verifyPeerPid, + transport: DesktopCoreTransportBinding(transportFactory()), + ); + } + + DesktopCoreLifecycle._({ + required this.transportFactory, + required this.launcherResolver, + required this.timeouts, + required this.sessionIdFactory, + required this.verifyPeerPid, + required DesktopCoreTransportBinding transport, + }) : _transport = transport { + _transportSubscription = _transport.events.listen(_handleTransportEvent); + } + + DesktopCoreTransport get transport => _transport; + + @override + DesktopCoreState get state => _state; + + @override + Stream get states => _stateController.stream; + + @override + Stream get crashEvents => _crashController.stream; + + @override + Future start() => _submit(_LifecycleTarget.running); + + @override + Future restart() => _submit(_LifecycleTarget.restarted); + + @override + Future stop() => _submit(_LifecycleTarget.stopped); + + @override + Future close() { + final closeResult = _closeResult; + if (closeResult != null) { + return closeResult; + } + final result = _submit(_LifecycleTarget.closed); + _closeResult = result; + return result; + } + + Future _submit(_LifecycleTarget target) { + if (_terminalRequested) { + return Future.error( + DesktopCoreFailure( + code: 'lifecycle_closed', + phase: DesktopCorePhase.closed, + revision: _revision, + ), + ); + } + if (target == _LifecycleTarget.closed) { + _terminalRequested = true; + } + final intent = _LifecycleIntent(++_revision, target); + final command = _PendingLifecycleCommand(intent); + _pending.add(command); + _desired = intent; + final changed = _intentChanged; + _intentChanged = Completer(); + if (!changed.isCompleted) { + changed.complete(); + } + _ensureWorker(); + return command.completer.future; + } + + void _ensureWorker() { + if (_worker != null) { + return; + } + late final Future worker; + worker = _runWorker().whenComplete(() { + if (identical(_worker, worker)) { + _worker = null; + } + if (_pending.isNotEmpty) { + _ensureWorker(); + } + }); + _worker = worker; + } + + Future _runWorker() async { + while (_pending.isNotEmpty) { + final recovery = _unexpectedDisconnectOperation; + if (recovery != null) { + await recovery; + } + final intent = _desired; + try { + final achievement = await _reconcile(intent); + final settled = _desired; + if (!_satisfies(achievement, settled.target)) { + continue; + } + _completeCommands(settled); + } catch (error, stackTrace) { + final failure = error is DesktopCoreFailure + ? error + : _failure( + code: 'lifecycle_error', + phase: _state.phase, + revision: intent.revision, + cause: error, + stackTrace: stackTrace, + ); + final desired = _desired; + if (desired.target == _LifecycleTarget.closed && + intent.target != _LifecycleTarget.closed) { + continue; + } + _publish(DesktopCoreFailed(failure)); + _failCommands(desired.revision, failure); + } + } + } + + Future<_LifecycleAchievement> _reconcile(_LifecycleIntent intent) async { + final unconfirmedLease = _unconfirmedLease; + if (unconfirmedLease != null) { + if (intent.target == _LifecycleTarget.closed) { + await _stopUnconfirmedLease(unconfirmedLease, allowFailure: true); + } else if (intent.target == _LifecycleTarget.stopped) { + await _stopUnconfirmedLease(unconfirmedLease, allowFailure: false); + } else { + throw _failure( + code: 'process_exit_unconfirmed', + phase: DesktopCorePhase.failed, + revision: intent.revision, + lease: unconfirmedLease, + ); + } + } + switch (intent.target) { + case _LifecycleTarget.running: + if (_session != null && _state is DesktopCoreRunning) { + return _LifecycleAchievement.runningExisting; + } + final started = await _startSession(intent.revision); + if (!started && _desired.target == _LifecycleTarget.stopped) { + _publish(const DesktopCoreIdle()); + } + return started + ? _LifecycleAchievement.runningFresh + : _LifecycleAchievement.idle; + case _LifecycleTarget.restarted: + final session = _session; + if (session != null) { + await _stopSession( + session, + intent.revision, + allowUnconfirmedExit: _terminalRequested, + ); + } + if (!_wantsRunning) { + return _LifecycleAchievement.idle; + } + final started = await _startSession(_desired.revision); + return started + ? _LifecycleAchievement.runningFresh + : _LifecycleAchievement.idle; + case _LifecycleTarget.stopped: + final session = _session; + if (session != null) { + await _stopSession( + session, + intent.revision, + allowUnconfirmedExit: false, + ); + } + if (_desired.target == _LifecycleTarget.stopped) { + _publish(const DesktopCoreIdle()); + } + return _LifecycleAchievement.idle; + case _LifecycleTarget.closed: + final session = _session; + if (session != null) { + await _stopSession( + session, + intent.revision, + allowUnconfirmedExit: true, + ); + } + await _transportSubscription.cancel(); + await _transport.close(); + _publish(DesktopCoreClosed(intent.revision)); + await _stateController.close(); + await _crashController.close(); + return _LifecycleAchievement.closed; + } + } + + bool get _wantsRunning { + return _desired.target == _LifecycleTarget.running || + _desired.target == _LifecycleTarget.restarted; + } + + Future _startSession(int revision) async { + final sessionId = sessionIdFactory(); + _publish(DesktopCoreStarting(revision: revision, sessionId: sessionId)); + CoreProcessLease? lease; + var leaseReleased = false; + _TransportConnectionWaiter? connectionWaiter; + + Future releaseLease() async { + final ownedLease = lease; + if (ownedLease == null || leaseReleased) { + return; + } + leaseReleased = true; + await _cleanObsoleteLease(ownedLease, revision); + } + + try { + if (!await _ensureTransportReady()) { + return false; + } + final launcher = await launcherResolver.resolve(); + if (!_wantsRunning) { + return false; + } + connectionWaiter = _TransportConnectionWaiter( + _transport.events, + timeouts.connection, + ); + lease = await launcher.start( + sessionId: sessionId, + address: _transport.address, + ); + if (lease.sessionId != sessionId) { + await releaseLease(); + throw _failure( + code: 'session_mismatch', + phase: DesktopCorePhase.starting, + revision: revision, + lease: lease, + ); + } + if (!_wantsRunning) { + await releaseLease(); + _publish(const DesktopCoreIdle()); + return false; + } + final connected = await _waitForConnectionWhileWanted( + connectionWaiter.future, + ); + if (connected == null || !_wantsRunning) { + await releaseLease(); + _publish(const DesktopCoreIdle()); + return false; + } + if (verifyPeerPid && connected.pid != lease.pid) { + await releaseLease(); + throw _failure( + code: 'peer_pid_mismatch', + phase: DesktopCorePhase.starting, + revision: revision, + lease: lease, + connectionGeneration: connected.generation, + cause: StateError( + 'Expected Core PID ${lease.pid}, connected ${connected.pid}', + ), + ); + } + final session = DesktopCoreSession( + sessionId: sessionId, + lease: lease, + connectionGeneration: connected.generation, + ); + _session = session; + leaseReleased = true; + _publish(DesktopCoreRunning(session)); + return true; + } catch (error, stackTrace) { + try { + await releaseLease(); + } on DesktopCoreFailure catch (cleanupFailure) { + Error.throwWithStackTrace( + cleanupFailure, + cleanupFailure.stackTrace ?? stackTrace, + ); + } + if (error is DesktopCoreFailure) { + Error.throwWithStackTrace(error, error.stackTrace ?? stackTrace); + } + throw _failure( + code: 'start_failed', + phase: DesktopCorePhase.starting, + revision: revision, + cause: error, + stackTrace: stackTrace, + ); + } finally { + await connectionWaiter?.cancel(); + } + } + + Future _ensureTransportReady() async { + if (_transport.state == DesktopTransportState.failed || + _transport.state == DesktopTransportState.closed || + (_transport.state == DesktopTransportState.connected && + _session == null)) { + await _transport.replace(transportFactory()); + } + if (_transport.state == DesktopTransportState.ready || + _transport.state == DesktopTransportState.connected) { + return true; + } + final opening = _transport.open().timeout(timeouts.ready); + while (_wantsRunning) { + final changed = _intentChanged.future; + final result = await Future.any([ + opening.then((_) => true), + changed.then((_) => false), + ]); + if (result == true) { + return true; + } + } + return false; + } + + Future _waitForConnectionWhileWanted( + Future connection, + ) async { + while (_wantsRunning) { + final changed = _intentChanged.future; + final result = await Future.any([ + connection, + changed.then((_) => null), + ]); + if (result is TransportConnected) { + return result; + } + } + return null; + } + + Future _cleanObsoleteLease(CoreProcessLease lease, int revision) async { + late final CoreProcessStopResult result; + try { + result = await lease.stop(timeouts.disconnection); + } catch (error, stackTrace) { + _unconfirmedLease = lease; + throw _failure( + code: 'process_cleanup_failed', + phase: DesktopCorePhase.stopping, + revision: revision, + lease: lease, + cause: error, + stackTrace: stackTrace, + ); + } + if (result.exitConfirmed) { + if (identical(_unconfirmedLease, lease)) { + _unconfirmedLease = null; + } + return; + } + _unconfirmedLease = lease; + throw _failure( + code: 'process_exit_unconfirmed', + phase: DesktopCorePhase.stopping, + revision: revision, + lease: lease, + ); + } + + Future _stopUnconfirmedLease( + CoreProcessLease lease, { + required bool allowFailure, + }) async { + try { + final result = await lease.stop(timeouts.disconnection); + if (result.exitConfirmed) { + _unconfirmedLease = null; + } else if (!allowFailure) { + throw _failure( + code: 'process_exit_unconfirmed', + phase: DesktopCorePhase.stopping, + revision: _desired.revision, + lease: lease, + ); + } + } catch (_) { + if (!allowFailure) { + rethrow; + } + } finally { + if (allowFailure) { + _unconfirmedLease = null; + } + } + } + + Future _stopSession( + DesktopCoreSession session, + int revision, { + required bool allowUnconfirmedExit, + }) async { + _publish(DesktopCoreStopping(revision: revision, session: session)); + final disconnected = Completer(); + late final StreamSubscription subscription; + subscription = _transport.events.listen((event) { + if (event case TransportDisconnected( + :final generation, + ) when generation == session.connectionGeneration) { + if (!disconnected.isCompleted) { + disconnected.complete(); + } + } + }); + try { + final stopResult = await session.lease.stop(timeouts.disconnection); + if (!stopResult.exitConfirmed) { + if (!allowUnconfirmedExit) { + throw _failure( + code: 'process_exit_unconfirmed', + phase: DesktopCorePhase.stopping, + revision: revision, + session: session, + ); + } + _session = null; + return; + } + try { + await disconnected.future.timeout(timeouts.disconnection); + } on TimeoutException { + _session = null; + if (!_terminalRequested) { + await _transport.replace(transportFactory()); + } + return; + } + _session = null; + } on DesktopCoreFailure { + rethrow; + } catch (error, stackTrace) { + if (allowUnconfirmedExit) { + _session = null; + return; + } + throw _failure( + code: 'stop_failed', + phase: DesktopCorePhase.stopping, + revision: revision, + session: session, + cause: error, + stackTrace: stackTrace, + ); + } finally { + await subscription.cancel(); + } + } + + bool _satisfies(_LifecycleAchievement achievement, _LifecycleTarget target) { + return switch (target) { + _LifecycleTarget.running => + achievement == _LifecycleAchievement.runningExisting || + achievement == _LifecycleAchievement.runningFresh, + _LifecycleTarget.restarted => + achievement == _LifecycleAchievement.runningFresh, + _LifecycleTarget.stopped => achievement == _LifecycleAchievement.idle, + _LifecycleTarget.closed => achievement == _LifecycleAchievement.closed, + }; + } + + void _completeCommands(_LifecycleIntent settled) { + final commands = _pending + .where((command) => command.intent.revision <= settled.revision) + .toList(growable: false); + _pending.removeWhere( + (command) => command.intent.revision <= settled.revision, + ); + for (final command in commands) { + final outcome = command.intent.revision == settled.revision + ? CoreLifecycleOutcome.applied + : _targetsCoalesce(command.intent.target, settled.target) + ? CoreLifecycleOutcome.coalesced + : CoreLifecycleOutcome.superseded; + command.completer.complete( + CoreLifecycleResult( + revision: command.intent.revision, + outcome: outcome, + session: _session, + ), + ); + } + } + + bool _targetsCoalesce(_LifecycleTarget first, _LifecycleTarget settled) { + final firstRuns = + first == _LifecycleTarget.running || + first == _LifecycleTarget.restarted; + final settledRuns = + settled == _LifecycleTarget.running || + settled == _LifecycleTarget.restarted; + return (firstRuns && settledRuns) || first == settled; + } + + void _failCommands(int revision, DesktopCoreFailure failure) { + final commands = _pending + .where((command) => command.intent.revision <= revision) + .toList(growable: false); + _pending.removeWhere((command) => command.intent.revision <= revision); + for (final command in commands) { + command.completer.completeError(failure, failure.stackTrace); + } + } + + void _handleTransportEvent(DesktopTransportEvent event) { + final running = _state; + if (running is! DesktopCoreRunning) { + return; + } + switch (event) { + case TransportDisconnected(:final generation) + when generation == running.session.connectionGeneration: + _handleUnexpectedDisconnect( + running.session, + code: 'unexpected_disconnect', + ); + case TransportFailed(:final error, :final stackTrace): + _handleUnexpectedDisconnect( + running.session, + code: 'transport_failed', + cause: error, + stackTrace: stackTrace, + ); + case TransportReady() || TransportConnected() || TransportDisconnected(): + break; + } + } + + void _handleUnexpectedDisconnect( + DesktopCoreSession session, { + required String code, + Object? cause, + StackTrace? stackTrace, + }) { + if (_unexpectedDisconnectOperation != null || + !identical(_session, session)) { + return; + } + _session = null; + final failure = _failure( + code: code, + phase: DesktopCorePhase.running, + revision: _revision, + session: session, + cause: cause, + stackTrace: stackTrace, + ); + _publish(DesktopCoreFailed(failure)); + if (!_crashController.isClosed) { + _crashController.add(failure); + } + late final Future operation; + operation = session.lease + .stop(timeouts.disconnection) + .then((result) { + if (!result.exitConfirmed) { + _unconfirmedLease = session.lease; + } + }) + .catchError((_) { + _unconfirmedLease = session.lease; + }) + .whenComplete(() { + if (identical(_unexpectedDisconnectOperation, operation)) { + _unexpectedDisconnectOperation = null; + } + }); + _unexpectedDisconnectOperation = operation; + } + + DesktopCoreFailure _failure({ + required String code, + required DesktopCorePhase phase, + required int revision, + DesktopCoreSession? session, + CoreProcessLease? lease, + int? connectionGeneration, + Object? cause, + StackTrace? stackTrace, + }) { + return DesktopCoreFailure( + code: code, + phase: phase, + revision: revision, + sessionId: session?.sessionId ?? lease?.sessionId, + owner: session?.owner ?? lease?.owner, + pid: session?.pid ?? lease?.pid, + connectionGeneration: + connectionGeneration ?? session?.connectionGeneration, + cause: cause, + stackTrace: stackTrace, + ); + } + + void _publish(DesktopCoreState state) { + _state = state; + if (!_stateController.isClosed) { + _stateController.add(state); + } + } + + @override + Future waitUntilRunning(Duration timeout) { + final current = _state; + if (current is DesktopCoreRunning) { + return Future.value(current.session); + } + if (current is DesktopCoreFailed) { + return Future.error(current.failure, current.failure.stackTrace); + } + if (current is DesktopCoreClosed) { + return Future.error(StateError('Desktop Core lifecycle is closed')); + } + return states + .firstWhere( + (state) => + state is DesktopCoreRunning || + state is DesktopCoreFailed || + state is DesktopCoreClosed, + ) + .then((state) { + if (state case DesktopCoreRunning(:final session)) { + return session; + } + if (state case DesktopCoreFailed(:final failure)) { + Error.throwWithStackTrace( + failure, + failure.stackTrace ?? StackTrace.current, + ); + } + throw StateError('Desktop Core lifecycle is closed'); + }) + .timeout(timeout); + } +} diff --git a/lib/core/desktop/model.dart b/lib/core/desktop/model.dart new file mode 100644 index 0000000000..caa9d08fc7 --- /dev/null +++ b/lib/core/desktop/model.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'dart:math'; + +enum CoreProcessOwner { direct, windowsHelper } + +enum CoreLifecycleOutcome { applied, coalesced, superseded } + +enum DesktopCorePhase { idle, starting, running, stopping, failed, closed } + +final class CoreLifecycleResult { + final int revision; + final CoreLifecycleOutcome outcome; + final DesktopCoreSession? session; + + const CoreLifecycleResult({ + required this.revision, + required this.outcome, + this.session, + }); +} + +final class DesktopCoreTimeouts { + final Duration ready; + final Duration connection; + final Duration disconnection; + + const DesktopCoreTimeouts({ + this.ready = const Duration(seconds: 10), + this.connection = const Duration(seconds: 10), + this.disconnection = const Duration(seconds: 10), + }); +} + +final class CoreProcessStopResult { + final bool stopped; + final bool exitConfirmed; + + const CoreProcessStopResult({ + required this.stopped, + required this.exitConfirmed, + }); + + @override + bool operator ==(Object other) { + return other is CoreProcessStopResult && + other.stopped == stopped && + other.exitConfirmed == exitConfirmed; + } + + @override + int get hashCode => Object.hash(stopped, exitConfirmed); +} + +abstract interface class CoreProcessLease { + String get sessionId; + + CoreProcessOwner get owner; + + int get pid; + + Future stop(Duration timeout); +} + +final class DesktopCoreSession { + final String sessionId; + final CoreProcessLease lease; + final int connectionGeneration; + + const DesktopCoreSession({ + required this.sessionId, + required this.lease, + required this.connectionGeneration, + }); + + CoreProcessOwner get owner => lease.owner; + + int get pid => lease.pid; +} + +final class DesktopCoreFailure implements Exception { + final String code; + final DesktopCorePhase phase; + final int revision; + final String? sessionId; + final CoreProcessOwner? owner; + final int? pid; + final int? connectionGeneration; + final Object? cause; + final StackTrace? stackTrace; + + const DesktopCoreFailure({ + required this.code, + required this.phase, + required this.revision, + this.sessionId, + this.owner, + this.pid, + this.connectionGeneration, + this.cause, + this.stackTrace, + }); + + @override + String toString() { + return 'DesktopCoreFailure($code, $phase, revision: $revision, ' + 'session: $sessionId, owner: $owner, pid: $pid, ' + 'generation: $connectionGeneration, cause: $cause)'; + } +} + +sealed class DesktopCoreState { + const DesktopCoreState(); + + DesktopCorePhase get phase; +} + +final class DesktopCoreIdle extends DesktopCoreState { + const DesktopCoreIdle(); + + @override + DesktopCorePhase get phase => DesktopCorePhase.idle; +} + +final class DesktopCoreStarting extends DesktopCoreState { + final int revision; + final String sessionId; + + const DesktopCoreStarting({required this.revision, required this.sessionId}); + + @override + DesktopCorePhase get phase => DesktopCorePhase.starting; +} + +final class DesktopCoreRunning extends DesktopCoreState { + final DesktopCoreSession session; + + const DesktopCoreRunning(this.session); + + @override + DesktopCorePhase get phase => DesktopCorePhase.running; +} + +final class DesktopCoreStopping extends DesktopCoreState { + final int revision; + final DesktopCoreSession session; + + const DesktopCoreStopping({required this.revision, required this.session}); + + @override + DesktopCorePhase get phase => DesktopCorePhase.stopping; +} + +final class DesktopCoreFailed extends DesktopCoreState { + final DesktopCoreFailure failure; + + const DesktopCoreFailed(this.failure); + + @override + DesktopCorePhase get phase => DesktopCorePhase.failed; +} + +final class DesktopCoreClosed extends DesktopCoreState { + final int revision; + + const DesktopCoreClosed(this.revision); + + @override + DesktopCorePhase get phase => DesktopCorePhase.closed; +} + +String createCoreSessionId() { + final random = Random.secure(); + return List.generate( + 16, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); +} diff --git a/lib/core/desktop/rpc_client.dart b/lib/core/desktop/rpc_client.dart new file mode 100644 index 0000000000..cdb6144eef --- /dev/null +++ b/lib/core/desktop/rpc_client.dart @@ -0,0 +1,208 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/core/event.dart'; +import 'package:fl_clash/core/method.dart'; +import 'package:fl_clash/enum/enum.dart'; + +import 'transport.dart'; + +abstract interface class CoreRpcChannel { + Future invoke({ + required CoreMethod method, + Object? arguments, + Duration? timeout, + }); + + Future close(); +} + +final class CoreRpcClient implements CoreRpcChannel { + final DesktopCoreTransport transport; + final Map> _pending = {}; + late final StreamSubscription _frameSubscription; + late final StreamSubscription _eventSubscription; + + int _methodCallId = 0; + Future? _closeOperation; + + CoreRpcClient(this.transport) { + _frameSubscription = transport.frames.listen( + _handleFrame, + onError: _handleFrameError, + ); + _eventSubscription = transport.events.listen(_handleTransportEvent); + } + + int get pendingCount => _pending.length; + + @override + Future invoke({ + required CoreMethod method, + Object? arguments, + Duration? timeout, + }) async { + if (_closeOperation != null) { + throw const CoreMethodException( + code: 'transport_disconnected', + message: 'Core RPC client is closed', + ); + } + final id = '${++_methodCallId}'; + final completer = Completer(); + _pending[id] = completer; + final requestTimeout = timeout ?? const Duration(minutes: 3); + final stopwatch = Stopwatch()..start(); + try { + await Future.any([ + transport.waitUntilConnected(const Duration(seconds: 10)), + completer.future, + ]); + if (completer.isCompleted) { + return await completer.future as T?; + } + await transport.send( + json.encode( + CoreMethodCall(id: id, method: method, arguments: arguments), + ), + ); + final remainingTimeout = requestTimeout - stopwatch.elapsed; + if (remainingTimeout <= Duration.zero) { + throw TimeoutException('Core method ${method.name} timed out'); + } + return await completer.future.timeout(remainingTimeout) as T?; + } on TimeoutException { + _removePending(id, completer); + return null; + } on CoreMethodException { + _pending.remove(id); + rethrow; + } catch (error) { + _removePending(id, completer); + throw CoreMethodException( + code: 'transport_error', + message: 'Unable to send ${method.name} to Core', + details: error.toString(), + ); + } finally { + stopwatch.stop(); + } + } + + void _removePending(String id, Completer completer) { + final removed = _pending.remove(id); + if (identical(removed, completer) && !completer.isCompleted) { + completer.complete(null); + } + } + + void _handleFrame(Uint8List frame) { + try { + final decoded = json.decode(utf8.decode(frame)); + if (decoded is! Map) { + throw const FormatException('Core transport data is not an object'); + } + final data = Map.from(decoded); + if (data.containsKey('method')) { + _handleMethodCall(CoreMethodCall.fromJson(data)); + } else { + _handleResponse(CoreMethodResponse.fromJson(data)); + } + } catch (error) { + commonPrint.log( + 'Failed to parse transport data: $error', + logLevel: LogLevel.error, + ); + } + } + + void _handleMethodCall(CoreMethodCall call) { + if (call.method != CoreMethod.message) { + commonPrint.log( + 'Unknown core callback method: ${call.method.name}', + logLevel: LogLevel.warning, + ); + return; + } + for (final event in coreEventsFromData(call.arguments)) { + coreEventManager.sendEvent(event); + } + } + + void _handleResponse(CoreMethodResponse response) { + final id = response.id; + final completer = id == null ? null : _pending.remove(id); + if (completer == null || completer.isCompleted) { + return; + } + final error = response.error; + if (error != null) { + completer.completeError( + CoreMethodException( + code: error.code, + message: error.message, + details: error.details, + ), + ); + return; + } + completer.complete(response.result); + } + + void _handleFrameError(Object error, StackTrace stackTrace) { + commonPrint.log( + 'Transport data stream error: $error', + logLevel: LogLevel.error, + ); + } + + void _handleTransportEvent(DesktopTransportEvent event) { + switch (event) { + case TransportDisconnected(): + _failPending( + const CoreMethodException( + code: 'transport_disconnected', + message: 'Core transport disconnected', + ), + ); + case TransportFailed(:final error): + _failPending( + CoreMethodException( + code: 'transport_error', + message: 'Core transport failed', + details: error.toString(), + ), + ); + case TransportReady() || TransportConnected(): + break; + } + } + + void _failPending(CoreMethodException error) { + final completers = _pending.values.toList(growable: false); + _pending.clear(); + for (final completer in completers) { + if (!completer.isCompleted) { + completer.completeError(error); + } + } + } + + @override + Future close() { + return _closeOperation ??= _close(); + } + + Future _close() async { + _failPending( + const CoreMethodException( + code: 'transport_disconnected', + message: 'Core RPC client is closed', + ), + ); + await _frameSubscription.cancel(); + await _eventSubscription.cancel(); + } +} diff --git a/lib/core/desktop/transport.dart b/lib/core/desktop/transport.dart new file mode 100644 index 0000000000..4de580583c --- /dev/null +++ b/lib/core/desktop/transport.dart @@ -0,0 +1,397 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/enum/enum.dart'; +import 'package:rust_api/rust_api.dart'; + +const _typeReady = 0x00; +const _typeConnected = 0x01; +const _typeDisconnected = 0x02; +const _typeData = 0x03; +const _typeError = 0x04; + +typedef IpcServerStarter = Stream Function(String address); +typedef IpcMessageSender = Future Function(List data); +typedef IpcServerStopper = Future Function(); + +enum DesktopTransportState { idle, starting, ready, connected, failed, closed } + +sealed class DesktopTransportEvent { + const DesktopTransportEvent(); +} + +final class TransportReady extends DesktopTransportEvent { + const TransportReady(); +} + +final class TransportConnected extends DesktopTransportEvent { + final int? pid; + final int generation; + + const TransportConnected({required this.pid, required this.generation}); +} + +final class TransportDisconnected extends DesktopTransportEvent { + final int generation; + + const TransportDisconnected(this.generation); +} + +final class TransportFailed extends DesktopTransportEvent { + final Object error; + final StackTrace stackTrace; + + const TransportFailed(this.error, this.stackTrace); +} + +abstract interface class DesktopCoreTransport { + String get address; + + DesktopTransportState get state; + + Stream get events; + + Stream get frames; + + Future open(); + + Future waitUntilConnected(Duration timeout); + + Future send(String message); + + Future close(); +} + +final class DesktopCoreTransportBinding implements DesktopCoreTransport { + DesktopCoreTransport _transport; + final StreamController _eventController = + StreamController.broadcast(); + final StreamController _frameController = + StreamController.broadcast(); + StreamSubscription? _eventSubscription; + StreamSubscription? _frameSubscription; + Future? _closeOperation; + + DesktopCoreTransportBinding(DesktopCoreTransport transport) + : _transport = transport { + _bind(transport); + } + + void _bind(DesktopCoreTransport transport) { + _eventSubscription = transport.events.listen( + _eventController.add, + onError: _eventController.addError, + ); + _frameSubscription = transport.frames.listen( + _frameController.add, + onError: _frameController.addError, + ); + } + + @override + String get address => _transport.address; + + @override + DesktopTransportState get state => _transport.state; + + @override + Stream get events => _eventController.stream; + + @override + Stream get frames => _frameController.stream; + + Future replace(DesktopCoreTransport next) async { + if (_closeOperation != null) { + throw StateError('IPC transport binding is closed'); + } + final previous = _transport; + if (previous.state == DesktopTransportState.connected && + !_eventController.isClosed) { + _eventController.add( + TransportFailed( + StateError('Connected IPC transport was replaced'), + StackTrace.current, + ), + ); + } + await _eventSubscription?.cancel(); + await _frameSubscription?.cancel(); + _eventSubscription = null; + _frameSubscription = null; + Object? closeError; + StackTrace? closeStackTrace; + try { + await previous.close(); + } catch (error, stackTrace) { + closeError = error; + closeStackTrace = stackTrace; + } + _transport = next; + _bind(next); + if (closeError != null) { + Error.throwWithStackTrace(closeError, closeStackTrace!); + } + } + + @override + Future open() => _transport.open(); + + @override + Future waitUntilConnected(Duration timeout) { + return _transport.waitUntilConnected(timeout); + } + + @override + Future send(String message) => _transport.send(message); + + @override + Future close() { + return _closeOperation ??= _close(); + } + + Future _close() async { + await _eventSubscription?.cancel(); + await _frameSubscription?.cancel(); + _eventSubscription = null; + _frameSubscription = null; + try { + await _transport.close(); + } finally { + await _eventController.close(); + await _frameController.close(); + } + } +} + +final class IPCCoreTransport implements DesktopCoreTransport { + @override + final String address; + + final Duration readyTimeout; + final IpcServerStarter _startServer; + final IpcMessageSender _sendMessage; + final IpcServerStopper _stopServer; + + final StreamController _eventController = + StreamController.broadcast(); + final StreamController _frameController = + StreamController.broadcast(); + + StreamSubscription? _subscription; + Future? _openOperation; + Future? _closeOperation; + DesktopTransportState _state = DesktopTransportState.idle; + TransportConnected? _connection; + TransportFailed? _failure; + int _connectionGeneration = 0; + + IPCCoreTransport({ + required this.address, + this.readyTimeout = const Duration(seconds: 10), + IpcServerStarter? startServer, + IpcMessageSender? sendMessage, + IpcServerStopper? stopServer, + }) : _startServer = startServer ?? _restartIpcServer, + _sendMessage = sendMessage ?? _sendIpcMessage, + _stopServer = stopServer ?? stopIpcServer; + + static Stream _restartIpcServer(String address) { + return restartIpcServer(name: address); + } + + static Future _sendIpcMessage(List data) { + return sendIpcMessage(data: data); + } + + @override + DesktopTransportState get state => _state; + + @override + Stream get events => _eventController.stream; + + @override + Stream get frames => _frameController.stream; + + @override + Future open() { + if (_state == DesktopTransportState.ready || + _state == DesktopTransportState.connected) { + return Future.value(); + } + if (_state == DesktopTransportState.closed) { + return Future.error(StateError('IPC transport is closed')); + } + return _openOperation ??= _open(); + } + + Future _open() async { + _state = DesktopTransportState.starting; + final readiness = events.firstWhere( + (event) => event is TransportReady || event is TransportFailed, + ); + try { + _subscription = _startServer(address).listen( + _handleFrame, + onError: _handleStreamError, + onDone: _handleStreamDone, + cancelOnError: false, + ); + final event = await readiness.timeout(readyTimeout); + if (event case TransportFailed(:final error, :final stackTrace)) { + Error.throwWithStackTrace(error, stackTrace); + } + } catch (error, stackTrace) { + if (_state != DesktopTransportState.failed && + _state != DesktopTransportState.closed) { + _fail(error, stackTrace); + } + await _subscription?.cancel(); + _subscription = null; + rethrow; + } + } + + void _handleFrame(Uint8List data) { + if (data.isEmpty || _state == DesktopTransportState.closed) { + return; + } + final type = data[0]; + final payload = data.length > 1 ? data.sublist(1) : Uint8List(0); + switch (type) { + case _typeReady: + commonPrint.log('IPC Ready'); + _state = DesktopTransportState.ready; + _eventController.add(const TransportReady()); + case _typeConnected: + final processId = _decodeConnectedProcessId(payload); + if (payload.isNotEmpty && processId == null) { + _fail(StateError('Invalid IPC connected frame'), StackTrace.current); + return; + } + commonPrint.log( + 'IPC Connected${processId == null ? '' : ': $processId'}', + ); + _connectionGeneration++; + _connection = TransportConnected( + pid: processId, + generation: _connectionGeneration, + ); + _state = DesktopTransportState.connected; + _eventController.add(_connection!); + case _typeDisconnected: + _disconnect(); + case _typeData: + if (!_frameController.isClosed) { + _frameController.add(payload); + } + case _typeError: + final message = utf8.decode(payload, allowMalformed: true); + _fail(StateError('IPC error: $message'), StackTrace.current); + default: + commonPrint.log( + 'IPC unknown frame type: $type', + logLevel: LogLevel.warning, + ); + } + } + + int? _decodeConnectedProcessId(Uint8List payload) { + if (payload.isEmpty) { + return null; + } + if (payload.length != Uint32List.bytesPerElement) { + return null; + } + final processId = ByteData.sublistView(payload).getUint32(0, Endian.little); + return processId == 0 ? null : processId; + } + + void _disconnect() { + final connection = _connection; + if (connection == null) { + return; + } + commonPrint.log('IPC Disconnected'); + _connection = null; + _state = DesktopTransportState.ready; + _eventController.add(TransportDisconnected(connection.generation)); + } + + void _handleStreamError(Object error, StackTrace stackTrace) { + _fail(error, stackTrace); + } + + void _handleStreamDone() { + if (_state == DesktopTransportState.closed) { + return; + } + _disconnect(); + _fail(StateError('IPC server stopped unexpectedly'), StackTrace.current); + } + + void _fail(Object error, StackTrace stackTrace) { + if (_state == DesktopTransportState.failed || + _state == DesktopTransportState.closed) { + return; + } + commonPrint.log('IPC error: $error', logLevel: LogLevel.error); + _state = DesktopTransportState.failed; + _failure = TransportFailed(error, stackTrace); + _eventController.add(_failure!); + } + + @override + Future waitUntilConnected(Duration timeout) { + final connection = _connection; + if (connection != null) { + return Future.value(connection); + } + final failure = _failure; + if (failure != null) { + return Future.error(failure.error, failure.stackTrace); + } + return events + .firstWhere( + (event) => event is TransportConnected || event is TransportFailed, + ) + .then((event) { + if (event case TransportConnected()) { + return event; + } + final failure = event as TransportFailed; + Error.throwWithStackTrace(failure.error, failure.stackTrace); + }) + .timeout(timeout); + } + + @override + Future send(String message) { + if (_state == DesktopTransportState.closed) { + return Future.error(StateError('IPC transport is closed')); + } + return _sendMessage(utf8.encode(message)); + } + + @override + Future close() { + return _closeOperation ??= _close(); + } + + Future _close() async { + if (_state == DesktopTransportState.closed) { + return; + } + _state = DesktopTransportState.closed; + _connection = null; + try { + await _stopServer(); + } finally { + await _subscription?.cancel(); + _subscription = null; + await _eventController.close(); + await _frameController.close(); + } + } +} diff --git a/lib/core/event.dart b/lib/core/event.dart index a6719f7dec..911ce36475 100644 --- a/lib/core/event.dart +++ b/lib/core/event.dart @@ -1,9 +1,26 @@ import 'dart:async'; +import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/models/models.dart'; import 'package:flutter/foundation.dart'; +List coreEventsFromData(Object? data) { + final items = data is List ? data : [data]; + final events = []; + for (final item in items.whereType()) { + try { + events.add(CoreEvent.fromJson(Map.from(item))); + } catch (error) { + commonPrint.log( + 'Unable to parse Core event: $error', + logLevel: LogLevel.error, + ); + } + } + return events; +} + abstract mixin class CoreEventListener { void onLog(Log log) {} @@ -29,31 +46,38 @@ class CoreEventManager { CoreEventManager._() { _controller.stream.listen((event) { for (final CoreEventListener listener in _listeners) { - switch (event.type) { - case CoreEventType.log: - listener.onLog(Log.fromJson(event.data)); - break; - case CoreEventType.delay: - listener.onDelay(Delay.fromJson(event.data)); - break; - case CoreEventType.request: - listener.onRequest(TrackerInfo.fromJson(event.data)); - break; - case CoreEventType.loaded: - listener.onLoaded(event.data); - break; - case CoreEventType.crash: - listener.onCrash(event.data); - break; - case CoreEventType.geoUpdate: - final data = event.data as Map; - listener.onGeoUpdate( - data['type'] as String, - data['updating'] as bool, - data['skipped'] as bool? ?? false, - data['error'] as String?, - ); - break; + try { + switch (event.type) { + case CoreEventType.log: + listener.onLog(Log.fromJson(event.data)); + break; + case CoreEventType.delay: + listener.onDelay(Delay.fromJson(event.data)); + break; + case CoreEventType.request: + listener.onRequest(TrackerInfo.fromJson(event.data)); + break; + case CoreEventType.loaded: + listener.onLoaded(event.data); + break; + case CoreEventType.crash: + listener.onCrash(event.data); + break; + case CoreEventType.geoUpdate: + final data = event.data as Map; + listener.onGeoUpdate( + data['type'] as String, + data['updating'] as bool, + data['skipped'] as bool? ?? false, + data['error'] as String?, + ); + break; + } + } catch (error) { + commonPrint.log( + 'Unable to dispatch Core event ${event.type.name}: $error', + logLevel: LogLevel.error, + ); } } }); diff --git a/lib/core/interface.dart b/lib/core/interface.dart index 8154017297..e14d6baa86 100644 --- a/lib/core/interface.dart +++ b/lib/core/interface.dart @@ -1,16 +1,21 @@ import 'dart:async'; -import 'dart:convert'; import 'package:fl_clash/common/common.dart'; -import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/models/models.dart'; +import 'desktop/model.dart'; +import 'method.dart'; + mixin CoreInterface { - Future init(InitParams params); + Future start(); + + Future restart(); - Future preload(); + Future stop(); - Future shutdown(bool isUser); + Future close(); + + Future init(InitParams params); Future get isInit; @@ -18,9 +23,9 @@ mixin CoreInterface { Future validateConfig(String path); - Future getConfig(String path); + Future> getConfig(String path); - Future asyncTestDelay(String url, String proxyName); + Future asyncTestDelay(String url, String proxyName); Future updateConfig(UpdateParams updateParams); @@ -34,9 +39,9 @@ mixin CoreInterface { Future stopListener(); - Future getExternalProviders(); + Future> getExternalProviders(); - Future? getExternalProvider(String externalProviderName); + Future getExternalProvider(String externalProviderName); Future updateGeoData(String type); @@ -47,13 +52,13 @@ mixin CoreInterface { Future updateExternalProvider(String providerName); - FutureOr getTraffic(bool onlyStatisticsProxy); + FutureOr getTraffic(bool onlyStatisticsProxy); - FutureOr getTotalTraffic(bool onlyStatisticsProxy); + FutureOr getTotalTraffic(bool onlyStatisticsProxy); FutureOr getCountryCode(String ip); - FutureOr getMemory(); + FutureOr getMemory(); FutureOr resetTraffic(); @@ -63,11 +68,11 @@ mixin CoreInterface { Future crash(); - FutureOr getConnections(); + FutureOr> getConnections(); FutureOr closeConnection(String id); - FutureOr deleteFile(String path); + FutureOr clearEffect(int profileId); FutureOr closeConnections(); @@ -75,114 +80,108 @@ mixin CoreInterface { } abstract class CoreHandlerInterface with CoreInterface { - Completer get completer; - - FutureOr destroy(); - - Future _invoke({ - required ActionMethod method, - dynamic data, + Future _invokeMethod({ + required CoreMethod method, + Object? arguments, Duration? timeout, }) async { - try { - await completer.future.timeout(const Duration(seconds: 10)); - } catch (e) { - commonPrint.log( - 'Invoke pre ${method.name} timeout $e', - logLevel: LogLevel.error, - ); - return null; - } return await utils.handleWatch( onStart: () { - commonPrint.log('Invoke ${method.name} ${DateTime.now()} $data'); + commonPrint.log( + 'Invoke method ${method.name} ${DateTime.now()} $arguments', + ); }, function: () async { - return invoke(method: method, data: data, timeout: timeout); + return invokeMethod( + method: method, + arguments: arguments, + timeout: timeout, + ); }, - onEnd: (data, elapsedMilliseconds) { - commonPrint.log('Invoke ${method.name} ${elapsedMilliseconds}ms'); + onEnd: (result, elapsedMilliseconds) { + commonPrint.log( + 'Invoke method ${method.name} completed in ${elapsedMilliseconds}ms', + ); }, ); } - Future invoke({ - required ActionMethod method, - dynamic data, + Future invokeMethod({ + required CoreMethod method, + Object? arguments, Duration? timeout, }); - Future parasResult(ActionResult result) async { - return switch (result.method) { - ActionMethod.getConfig => result.toResult as T, - _ => result.data as T, - }; - } - @override Future init(InitParams params) async { - return await _invoke( - method: ActionMethod.initClash, - data: json.encode(params), + return await _invokeMethod( + method: CoreMethod.initClash, + arguments: params.toJson(), ) ?? false; } - @override - Future shutdown(bool isUser); - @override Future get isInit async { - return await _invoke(method: ActionMethod.getIsInit) ?? false; + return await _invokeMethod(method: CoreMethod.getIsInit) ?? false; } @override Future forceGc() async { - return await _invoke(method: ActionMethod.forceGc) ?? false; + return await _invokeMethod(method: CoreMethod.forceGc) ?? false; } @override Future validateConfig(String path) async { - return await _invoke( - method: ActionMethod.validateConfig, - data: path, + return await _invokeMethod( + method: CoreMethod.validateConfig, + arguments: path, ) ?? ''; } @override Future updateConfig(UpdateParams updateParams) async { - return await _invoke( - method: ActionMethod.updateConfig, - data: json.encode(updateParams), + return await _invokeMethod( + method: CoreMethod.updateConfig, + arguments: updateParams.toJson(), ) ?? ''; } @override - Future getConfig(String path) async { - final res = await _invoke(method: ActionMethod.getConfig, data: path); - return res ?? Result.success({}); + Future> getConfig(String path) async { + final result = await _invokeMethod>( + method: CoreMethod.getConfig, + arguments: path, + ); + if (result == null) { + throw const CoreMethodException( + code: 'empty_result', + message: 'Core returned an empty config result', + ); + } + return result; } @override Future setupConfig(SetupParams setupParams) async { - return await _invoke( - method: ActionMethod.setupConfig, - data: json.encode(setupParams), + return await _invokeMethod( + method: CoreMethod.setupConfig, + arguments: setupParams.toJson(), ) ?? ''; } @override Future crash() async { - return await _invoke(method: ActionMethod.crash) ?? false; + return await _invokeMethod(method: CoreMethod.crash) ?? false; } @override Future getProxies() async { - final data = await _invoke>( - method: ActionMethod.getProxies, + final data = await _invokeMethod>( + method: CoreMethod.getProxies, ); return data != null ? ProxiesData.fromJson(data) @@ -191,33 +190,44 @@ abstract class CoreHandlerInterface with CoreInterface { @override Future changeProxy(ChangeProxyParams changeProxyParams) async { - return await _invoke( - method: ActionMethod.changeProxy, - data: json.encode(changeProxyParams), + return await _invokeMethod( + method: CoreMethod.changeProxy, + arguments: changeProxyParams.toJson(), ) ?? ''; } @override - Future getExternalProviders() async { - return await _invoke(method: ActionMethod.getExternalProviders) ?? - ''; + Future> getExternalProviders() async { + final data = await _invokeMethod>( + method: CoreMethod.getExternalProviders, + ); + return data + ?.whereType() + .map( + (item) => + ExternalProvider.fromJson(Map.from(item)), + ) + .toList() ?? + []; } @override - Future getExternalProvider(String externalProviderName) async { - return await _invoke( - method: ActionMethod.getExternalProvider, - data: externalProviderName, - ) ?? - ''; + Future getExternalProvider( + String externalProviderName, + ) async { + final data = await _invokeMethod>( + method: CoreMethod.getExternalProvider, + arguments: externalProviderName, + ); + return data == null ? null : ExternalProvider.fromJson(data); } @override Future updateGeoData(String type) async { - return await _invoke( - method: ActionMethod.updateGeoData, - data: type, + return await _invokeMethod( + method: CoreMethod.updateGeoData, + arguments: type, ) ?? ''; } @@ -227,121 +237,138 @@ abstract class CoreHandlerInterface with CoreInterface { required String providerName, required String data, }) async { - return await _invoke( - method: ActionMethod.sideLoadExternalProvider, - data: json.encode({'providerName': providerName, 'data': data}), + return await _invokeMethod( + method: CoreMethod.sideLoadExternalProvider, + arguments: {'providerName': providerName, 'data': data}, ) ?? ''; } @override Future updateExternalProvider(String providerName) async { - return await _invoke( - method: ActionMethod.updateExternalProvider, - data: providerName, + return await _invokeMethod( + method: CoreMethod.updateExternalProvider, + arguments: providerName, ) ?? ''; } @override - Future getConnections() async { - return await _invoke(method: ActionMethod.getConnections) ?? ''; + Future> getConnections() async { + final data = await _invokeMethod>( + method: CoreMethod.getConnections, + ); + final connections = data?['connections']; + if (connections is! List) { + return []; + } + return connections + .whereType() + .map((item) => TrackerInfo.fromJson(Map.from(item))) + .toList(); } @override Future closeConnections() async { - return await _invoke(method: ActionMethod.closeConnections) ?? false; + return await _invokeMethod(method: CoreMethod.closeConnections) ?? + false; } @override Future resetConnections() async { - return await _invoke(method: ActionMethod.resetConnections) ?? false; + return await _invokeMethod(method: CoreMethod.resetConnections) ?? + false; } @override Future closeConnection(String id) async { - return await _invoke( - method: ActionMethod.closeConnection, - data: id, + return await _invokeMethod( + method: CoreMethod.closeConnection, + arguments: id, ) ?? false; } @override - Future getTotalTraffic(bool onlyStatisticsProxy) async { - return await _invoke( - method: ActionMethod.getTotalTraffic, - data: onlyStatisticsProxy, - ) ?? - ''; + Future getTotalTraffic(bool onlyStatisticsProxy) async { + final data = await _invokeMethod>( + method: CoreMethod.getTotalTraffic, + arguments: onlyStatisticsProxy, + ); + return data == null ? const Traffic() : Traffic.fromJson(data); } @override - Future getTraffic(bool onlyStatisticsProxy) async { - return await _invoke( - method: ActionMethod.getTraffic, - data: onlyStatisticsProxy, - ) ?? - ''; + Future getTraffic(bool onlyStatisticsProxy) async { + final data = await _invokeMethod>( + method: CoreMethod.getTraffic, + arguments: onlyStatisticsProxy, + ); + return data == null ? const Traffic() : Traffic.fromJson(data); } @override - Future deleteFile(String path) async { - return await _invoke(method: ActionMethod.deleteFile, data: path) ?? + Future clearEffect(int profileId) async { + return await _invokeMethod( + method: CoreMethod.clearEffect, + arguments: profileId, + ) ?? ''; } @override FutureOr resetTraffic() { - _invoke(method: ActionMethod.resetTraffic); + _invokeMethod(method: CoreMethod.resetTraffic); } @override FutureOr startLog() { - _invoke(method: ActionMethod.startLog); + _invokeMethod(method: CoreMethod.startLog); } @override FutureOr stopLog() { - _invoke(method: ActionMethod.stopLog); + _invokeMethod(method: CoreMethod.stopLog); } @override Future startListener() async { - return await _invoke(method: ActionMethod.startListener) ?? false; + return await _invokeMethod(method: CoreMethod.startListener) ?? false; } @override Future stopListener() async { - return await _invoke(method: ActionMethod.stopListener) ?? false; + return await _invokeMethod(method: CoreMethod.stopListener) ?? false; } @override - Future asyncTestDelay(String url, String proxyName) async { + Future asyncTestDelay(String url, String proxyName) async { final delayParams = { 'proxy-name': proxyName, 'timeout': httpTimeoutDuration.inMilliseconds, 'test-url': url, }; - return await _invoke( - method: ActionMethod.asyncTestDelay, - data: json.encode(delayParams), - timeout: const Duration(seconds: 6), - ) ?? - json.encode(Delay(name: proxyName, value: -1, url: url)); + final data = await _invokeMethod>( + method: CoreMethod.asyncTestDelay, + arguments: delayParams, + timeout: const Duration(seconds: 6), + ); + return data == null + ? Delay(name: proxyName, value: -1, url: url) + : Delay.fromJson(data); } @override Future getCountryCode(String ip) async { - return await _invoke( - method: ActionMethod.getCountryCode, - data: ip, + return await _invokeMethod( + method: CoreMethod.getCountryCode, + arguments: ip, ) ?? ''; } @override - Future getMemory() async { - return await _invoke(method: ActionMethod.getMemory) ?? ''; + Future getMemory() async { + return await _invokeMethod(method: CoreMethod.getMemory) ?? 0; } } diff --git a/lib/core/lib.dart b/lib/core/lib.dart index 63369acecd..16a8e5740a 100644 --- a/lib/core/lib.dart +++ b/lib/core/lib.dart @@ -2,87 +2,144 @@ import 'dart:async'; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/enum/enum.dart'; -import 'package:fl_clash/models/core.dart'; import 'package:fl_clash/plugins/service.dart'; import 'package:fl_clash/providers/providers.dart'; import 'package:fl_clash/state.dart'; +import 'desktop/model.dart'; import 'interface.dart'; +import 'method.dart'; class CoreLib extends CoreHandlerInterface { static CoreLib? _instance; - Completer _connectedCompleter = Completer(); + Completer _connectedCompleter = Completer(); + Future? _closeOperation; + int _lifecycleRevision = 0; + int _methodCallId = 0; + bool _closed = false; CoreLib._internal(); + factory CoreLib() { + _instance ??= CoreLib._internal(); + return _instance!; + } + @override - Future preload() async { + Future start() async { + if (_closed) { + throw StateError('Core lifecycle is closed'); + } + final revision = ++_lifecycleRevision; if (_connectedCompleter.isCompleted) { - return 'core is connected'; + return CoreLifecycleResult( + revision: revision, + outcome: CoreLifecycleOutcome.coalesced, + ); } - final res = await service?.init(); - if (res?.isEmpty != true) { - return res ?? ''; + final initializationError = await service?.init() ?? ''; + if (initializationError.isNotEmpty) { + throw StateError(initializationError); } _connectedCompleter.complete(true); - final syncRes = await service?.syncState( - globalState.container.read(sharedStateProvider), + final syncError = + await service?.syncState( + globalState.container.read(sharedStateProvider), + ) ?? + ''; + if (syncError.isNotEmpty) { + _connectedCompleter = Completer(); + await service?.shutdown(); + throw StateError(syncError); + } + return CoreLifecycleResult( + revision: revision, + outcome: CoreLifecycleOutcome.applied, ); - return syncRes ?? ''; - } - - factory CoreLib() { - _instance ??= CoreLib._internal(); - return _instance!; } @override - FutureOr destroy() async { - return true; + Future restart() async { + await stop(); + return start(); } @override - Future shutdown(_) async { + Future stop() => _stop(); + + Future _stop({bool allowClosed = false}) async { + if (_closed && !allowClosed) { + throw StateError('Core lifecycle is closed'); + } + final revision = ++_lifecycleRevision; if (!_connectedCompleter.isCompleted) { - return false; + return CoreLifecycleResult( + revision: revision, + outcome: CoreLifecycleOutcome.coalesced, + ); + } + _connectedCompleter = Completer(); + final stopped = await service?.shutdown() ?? true; + if (!stopped) { + throw StateError('Android Core service shutdown failed'); } - _connectedCompleter = Completer(); - return service?.shutdown() ?? true; + return CoreLifecycleResult( + revision: revision, + outcome: CoreLifecycleOutcome.applied, + ); + } + + @override + Future close() { + return _closeOperation ??= _close(); + } + + Future _close() async { + _closed = true; + return _stop(allowClosed: true); } @override Future startListener() async { - await super.startListener(); - await service?.start(); - return true; + final listenerStarted = await super.startListener(); + final serviceStarted = await service?.start() ?? false; + return listenerStarted && serviceStarted; } @override Future stopListener() async { - await super.stopListener(); - await service?.stop(); - return true; + final serviceStopped = await service?.stop() ?? false; + final listenerStopped = await super.stopListener(); + return serviceStopped && listenerStopped; } @override - Future invoke({ - required ActionMethod method, - dynamic data, + Future invokeMethod({ + required CoreMethod method, + Object? arguments, Duration? timeout, }) async { - final id = '${method.name}#${utils.id}'; - final result = await service - ?.invokeAction(Action(id: id, method: method, data: data)) - .withTimeout(onTimeout: () => null); - if (result == null) { + try { + await _connectedCompleter.future.timeout(const Duration(seconds: 10)); + } catch (error) { + commonPrint.log( + 'Invoke method ${method.name} before connection timed out: $error', + logLevel: LogLevel.error, + ); + return null; + } + final id = '${++_methodCallId}'; + final response = await service + ?.invokeMethod( + CoreMethodCall(id: id, method: method, arguments: arguments), + ) + .withTimeout(timeout: timeout, onTimeout: () => null); + if (response == null) { return null; } - return parasResult(result); + return response.unwrap(); } - - @override - Completer get completer => _connectedCompleter; } CoreLib? get coreLib => system.isAndroid ? CoreLib() : null; diff --git a/lib/core/method.dart b/lib/core/method.dart new file mode 100644 index 0000000000..69c28a70c3 --- /dev/null +++ b/lib/core/method.dart @@ -0,0 +1,137 @@ +enum CoreMethod { + message, + initClash, + getIsInit, + forceGc, + shutdown, + validateConfig, + updateConfig, + getConfig, + getProxies, + changeProxy, + getTraffic, + getTotalTraffic, + resetTraffic, + asyncTestDelay, + getConnections, + closeConnections, + resetConnections, + closeConnection, + getExternalProviders, + getExternalProvider, + updateGeoData, + updateExternalProvider, + sideLoadExternalProvider, + startLog, + stopLog, + startListener, + stopListener, + getCountryCode, + getMemory, + crash, + setupConfig, + clearEffect, + updateDns, +} + +class CoreMethodCall { + final String? id; + final CoreMethod method; + final Object? arguments; + + const CoreMethodCall({this.id, required this.method, this.arguments}); + + factory CoreMethodCall.fromJson(Map json) { + return CoreMethodCall( + id: json['id'] as String?, + method: CoreMethod.values.byName(json['method'] as String), + arguments: json['arguments'], + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'method': method.name, + 'arguments': arguments, + }; + } +} + +class CoreMethodError { + final String code; + final String message; + final Object? details; + + const CoreMethodError({ + required this.code, + required this.message, + this.details, + }); + + factory CoreMethodError.fromJson(Map json) { + return CoreMethodError( + code: json['code'] as String, + message: json['message'] as String, + details: json['details'], + ); + } + + Map toJson() { + return {'code': code, 'message': message, 'details': details}; + } +} + +class CoreMethodResponse { + final String? id; + final Object? result; + final CoreMethodError? error; + + const CoreMethodResponse({this.id, this.result, this.error}); + + factory CoreMethodResponse.fromJson(Map json) { + final error = json['error']; + return CoreMethodResponse( + id: json['id'] as String?, + result: json['result'], + error: error is Map + ? CoreMethodError.fromJson(Map.from(error)) + : null, + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'result': result, + if (error != null) 'error': error!.toJson(), + }; + } + + T? unwrap() { + final error = this.error; + if (error != null) { + throw CoreMethodException( + code: error.code, + message: error.message, + details: error.details, + ); + } + return result as T?; + } +} + +class CoreMethodException implements Exception { + final String code; + final String message; + final Object? details; + + const CoreMethodException({ + required this.code, + required this.message, + this.details, + }); + + @override + String toString() => 'CoreMethodException($code, $message, $details)'; +} diff --git a/lib/core/service.dart b/lib/core/service.dart index 737eb39e32..a187531421 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -1,185 +1,109 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:fl_clash/common/common.dart'; -import 'package:fl_clash/core/core.dart'; +import 'package:fl_clash/common/constant.dart'; +import 'package:fl_clash/common/system.dart'; import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/models/core.dart'; - +import 'package:flutter/foundation.dart'; + +import 'desktop/helper_client.dart'; +import 'desktop/launcher.dart'; +import 'desktop/lifecycle.dart'; +import 'desktop/model.dart'; +import 'desktop/rpc_client.dart'; +import 'desktop/transport.dart'; +import 'event.dart'; import 'interface.dart'; -import 'transport.dart'; +import 'method.dart'; class CoreService extends CoreHandlerInterface { static CoreService? _instance; - late final IPCCoreTransport _transport; - - Completer _shutdownCompleter = Completer(); - - final Map _callbackCompleterMap = {}; - - Process? _process; + final DesktopCoreLifecycleController _lifecycle; + final CoreRpcChannel _rpcClient; + late final StreamSubscription _crashSubscription; + Future? _closeOperation; factory CoreService() { - _instance ??= CoreService._internal(); - return _instance!; + return _instance ??= CoreService._create(); } - CoreService._internal() { - _transport = IPCCoreTransport( - address: system.isWindows ? windowsPipeName : unixSocketPath, + factory CoreService._create() { + final address = system.isWindows ? windowsPipeName : unixSocketPath; + final directLauncher = DirectCoreLauncher(); + final helperLauncher = WindowsHelperLauncher(windowsHelperClient); + final lifecycle = DesktopCoreLifecycle( + transportFactory: () => IPCCoreTransport(address: address), + launcherResolver: WindowsHelperLauncherResolver( + isWindows: system.isWindows, + directLauncher: directLauncher, + helperLauncher: helperLauncher, + helperReady: windowsHelperClient.isReady, + ), + verifyPeerPid: system.isWindows, ); - _initServer(); - } - - Future handleResult(ActionResult result) async { - final completer = _callbackCompleterMap[result.id]; - final data = await parasResult(result); - if (result.id?.isEmpty == true) { - coreEventManager.sendEvent(CoreEvent.fromJson(result.data)); - } - if (completer?.isCompleted == true) { - return; - } - completer?.complete(data); - } - - Future _initServer() async { - await _transport.init(); - - _transport.onDisconnect = () { - _handleInvokeCrashEvent(); - if (!_shutdownCompleter.isCompleted) { - _shutdownCompleter.complete(true); - } - }; - - _transport.dataStream - .transform(uint8ListToListIntConverter) - .transform(utf8.decoder) - .listen( - (data) async { - try { - final dataJson = await data.trim().commonToJSON(); - handleResult(ActionResult.fromJson(dataJson)); - } catch (e) { - commonPrint.log( - 'Failed to parse transport data: $e', - logLevel: LogLevel.error, - ); - } - }, - onError: (error) { - commonPrint.log( - 'Transport data stream error: $error', - logLevel: LogLevel.error, - ); - }, - ); - } - - void _handleInvokeCrashEvent() { - coreEventManager.sendEvent( - const CoreEvent(type: CoreEventType.crash, data: 'core done'), + return CoreService._( + lifecycle: lifecycle, + rpcClient: CoreRpcClient(lifecycle.transport), ); } - Future start() async { - if (_process != null) { - await shutdown(false); - } - if (system.isWindows && await system.checkIsAdmin()) { - final isSuccess = await request.startCoreByHelper(_transport.address); - if (isSuccess) { - await _transport.connectionCompleter.future; - return; - } - } - try { - _process = await Process.start(appPath.corePath, [_transport.address]); - } catch (e) { - commonPrint.log( - 'Failed to start core process: $e', - logLevel: LogLevel.error, + @visibleForTesting + CoreService.forTesting({ + required DesktopCoreLifecycleController lifecycle, + required CoreRpcChannel rpcClient, + }) : this._(lifecycle: lifecycle, rpcClient: rpcClient); + + CoreService._({ + required DesktopCoreLifecycleController lifecycle, + required CoreRpcChannel rpcClient, + }) : _lifecycle = lifecycle, + _rpcClient = rpcClient { + _crashSubscription = _lifecycle.crashEvents.listen((failure) { + coreEventManager.sendEvent( + CoreEvent( + type: CoreEventType.crash, + data: failure.cause?.toString() ?? 'core done', + ), ); - _handleInvokeCrashEvent(); - return; - } - _process?.stdout.listen((_) {}); - _process?.stderr.listen((e) { - final error = utf8.decode(e); - if (error.isNotEmpty) { - commonPrint.log(error, logLevel: LogLevel.warning); - } }); - await _transport.connectionCompleter.future; } @override - FutureOr destroy() async { - await shutdown(false); - await _transport.close(); - return true; - } + Future start() => _lifecycle.start(); - Future sendMessage(String message) async { - await _transport.connectionCompleter.future; - _transport.send(message); - } + @override + Future restart() => _lifecycle.restart(); @override - Future shutdown(bool isUser) async { - _shutdownCompleter = Completer(); - if (system.isWindows) { - await request.stopCoreByHelper(); - } - _transport.disconnected(); - _process?.kill(); - _process = null; - _clearCompleter(); - if (isUser) { - return _shutdownCompleter.future; - } else { - return true; - } - } + Future stop() => _lifecycle.stop(); - void _clearCompleter() { - for (final completer in _callbackCompleterMap.values) { - completer.safeCompleter(null); - } + @override + Future close() { + return _closeOperation ??= _close(); } - @override - Future preload() async { - await start(); - return ''; + Future _close() async { + try { + return await _lifecycle.close(); + } finally { + await _rpcClient.close(); + await _crashSubscription.cancel(); + } } @override - Future invoke({ - required ActionMethod method, - dynamic data, + Future invokeMethod({ + required CoreMethod method, + Object? arguments, Duration? timeout, - }) async { - final id = '${method.name}#${utils.id}'; - _callbackCompleterMap[id] = Completer(); - sendMessage(json.encode(Action(id: id, method: method, data: data))); - return (_callbackCompleterMap[id] as Completer).future.withTimeout( + }) { + return _rpcClient.invoke( + method: method, + arguments: arguments, timeout: timeout, - onLast: () { - final completer = _callbackCompleterMap[id]; - completer?.safeCompleter(null); - _callbackCompleterMap.remove(id); - }, - tag: id, - onTimeout: () => null, ); } - - @override - Completer get completer => _transport.connectionCompleter; } final coreService = system.isDesktop ? CoreService() : null; diff --git a/lib/core/transport.dart b/lib/core/transport.dart deleted file mode 100644 index e7481bca98..0000000000 --- a/lib/core/transport.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:fl_clash/common/common.dart'; -import 'package:fl_clash/enum/enum.dart'; -import 'package:rust_api/rust_api.dart'; - -// โ”€โ”€ Binary frame types (mirrors Rust ipc.rs) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -const _typeReady = 0x00; -const _typeConnected = 0x01; -const _typeDisconnected = 0x02; -const _typeData = 0x03; -const _typeError = 0x04; - -class IPCCoreTransport { - final String address; - final StreamController _dataController = - StreamController(); - StreamSubscription? _subscription; - Completer _completer = Completer(); - Completer _readyCompleter = Completer(); - - void Function()? onDisconnect; - - IPCCoreTransport({required this.address}); - - Completer get connectionCompleter => _completer; - - Stream get dataStream => _dataController.stream; - - Future init() async { - try { - final stream = restartIpcServer(name: address); - _subscription = stream.listen( - (data) { - if (data.isEmpty) return; - final type = data[0]; - final payload = data.length > 1 ? data.sublist(1) : Uint8List(0); - switch (type) { - case _typeReady: - commonPrint.log('IPC Ready'); - if (_readyCompleter.isCompleted) { - break; - } - _readyCompleter.complete(); - break; - case _typeConnected: - commonPrint.log('IPC Connected'); - if (_completer.isCompleted) { - break; - } - _completer.complete(); - break; - case _typeDisconnected: - commonPrint.log('IPC Disconnected'); - _completer = Completer(); - onDisconnect?.call(); - break; - case _typeData: - _dataController.add(payload); - break; - case _typeError: - final msg = utf8.decode(payload); - commonPrint.log('IPC error: $msg', logLevel: LogLevel.error); - break; - default: - commonPrint.log( - 'IPC unknown frame type: $type', - logLevel: LogLevel.warning, - ); - } - }, - onError: (error) { - commonPrint.log('IPC error: $error', logLevel: LogLevel.error); - }, - cancelOnError: false, - ); - await _readyCompleter.future; - } catch (e) { - commonPrint.log( - 'Failed to start IPC server: $e', - logLevel: LogLevel.error, - ); - rethrow; - } - } - - void send(String message) { - sendIpcMessage(data: utf8.encode(message)); - } - - void disconnected() { - _completer = Completer(); - } - - Future close() async { - await _subscription?.cancel(); - _subscription = null; - await stopIpcServer(); - _readyCompleter = Completer(); - _completer = Completer(); - await _dataController.close(); - } -} diff --git a/lib/database/generated/database.g.dart b/lib/database/generated/database.g.dart index 7748f403c4..239d2a2265 100644 --- a/lib/database/generated/database.g.dart +++ b/lib/database/generated/database.g.dart @@ -3505,10 +3505,7 @@ final class $$ProfilesTableReferences static MultiTypedResultKey<$ProfileRuleLinksTable, List> _profileRuleLinksRefsTable(_$Database db) => MultiTypedResultKey.fromTable( db.profileRuleLinks, - aliasName: $_aliasNameGenerator( - db.profiles.id, - db.profileRuleLinks.profileId, - ), + aliasName: 'profiles__id__profile_rule_mapping__profile_id', ); $$ProfileRuleLinksTableProcessedTableManager get profileRuleLinksRefs { @@ -3528,7 +3525,7 @@ final class $$ProfilesTableReferences static MultiTypedResultKey<$ProxyGroupsTable, List> _proxyGroupsRefsTable(_$Database db) => MultiTypedResultKey.fromTable( db.proxyGroups, - aliasName: $_aliasNameGenerator(db.profiles.id, db.proxyGroups.profileId), + aliasName: 'profiles__id__proxy_groups__profile_id', ); $$ProxyGroupsTableProcessedTableManager get proxyGroupsRefs { @@ -4223,7 +4220,7 @@ final class $$RulesTableReferences static MultiTypedResultKey<$ProfileRuleLinksTable, List> _profileRuleLinksRefsTable(_$Database db) => MultiTypedResultKey.fromTable( db.profileRuleLinks, - aliasName: $_aliasNameGenerator(db.rules.id, db.profileRuleLinks.ruleId), + aliasName: 'rules__id__profile_rule_mapping__rule_id', ); $$ProfileRuleLinksTableProcessedTableManager get profileRuleLinksRefs { @@ -4580,9 +4577,7 @@ final class $$ProfileRuleLinksTableReferences ); static $ProfilesTable _profileIdTable(_$Database db) => - db.profiles.createAlias( - $_aliasNameGenerator(db.profileRuleLinks.profileId, db.profiles.id), - ); + db.profiles.createAlias('profile_rule_mapping__profile_id__profiles__id'); $$ProfilesTableProcessedTableManager? get profileId { final $_column = $_itemColumn('profile_id'); @@ -4598,9 +4593,8 @@ final class $$ProfileRuleLinksTableReferences ); } - static $RulesTable _ruleIdTable(_$Database db) => db.rules.createAlias( - $_aliasNameGenerator(db.profileRuleLinks.ruleId, db.rules.id), - ); + static $RulesTable _ruleIdTable(_$Database db) => + db.rules.createAlias('profile_rule_mapping__rule_id__rules__id'); $$RulesTableProcessedTableManager get ruleId { final $_column = $_itemColumn('rule_id')!; @@ -5025,9 +5019,7 @@ final class $$ProxyGroupsTableReferences $$ProxyGroupsTableReferences(super.$_db, super.$_table, super.$_typedResult); static $ProfilesTable _profileIdTable(_$Database db) => - db.profiles.createAlias( - $_aliasNameGenerator(db.proxyGroups.profileId, db.profiles.id), - ); + db.profiles.createAlias('proxy_groups__profile_id__profiles__id'); $$ProfilesTableProcessedTableManager? get profileId { final $_column = $_itemColumn('profile_id'); diff --git a/lib/database/rules.dart b/lib/database/rules.dart index 1e4ec052fc..eedfb776fb 100644 --- a/lib/database/rules.dart +++ b/lib/database/rules.dart @@ -74,13 +74,13 @@ class RulesDao extends DatabaseAccessor with _$RulesDaoMixin { ); query.orderBy([ - OrderingTerm.desc( + OrderingTerm.asc( profileRuleLinks.profileId.isNull().caseMatch( when: {const Constant(true): const Constant(1)}, orElse: const Constant(0), ), ), - OrderingTerm.desc(profileRuleLinks.order), + OrderingTerm.asc(profileRuleLinks.order), ]); return query.map((row) { diff --git a/lib/enum/enum.dart b/lib/enum/enum.dart index 6a25b6458a..28a2c5784d 100644 --- a/lib/enum/enum.dart +++ b/lib/enum/enum.dart @@ -219,58 +219,13 @@ enum FontFamily { enum RouteMode { bypassPrivate, config } -enum ActionMethod { - message, - initClash, - getIsInit, - forceGc, - shutdown, - validateConfig, - updateConfig, - getConfig, - getProxies, - changeProxy, - getTraffic, - getTotalTraffic, - resetTraffic, - asyncTestDelay, - getConnections, - closeConnections, - resetConnections, - closeConnection, - getExternalProviders, - getExternalProvider, - updateGeoData, - updateExternalProvider, - sideLoadExternalProvider, - startLog, - stopLog, - startListener, - stopListener, - getCountryCode, - getMemory, - crash, - setupConfig, - deleteFile, - - ///Android, - setState, - startTun, - stopTun, - getRunTime, - updateDns, - getAndroidVpnOptions, - getCurrentProfileName, -} - enum AuthorizeCode { none, success, error } -enum WindowsHelperServiceStatus { none, presence, running } +enum TunAuthorizationState { none, authorized, unauthorized } enum FunctionTag { updateConfig, setupConfig, - updateStatus, updateGroups, addCheckIpNum, applyProfile, @@ -336,9 +291,9 @@ enum GeoResource { MMDB, @JsonValue('asn') ASN, - @JsonValue('geo-ip') + @JsonValue('geoip') GEOIP, - @JsonValue('geo-site') + @JsonValue('geosite') GEOSITE; static GeoResource fromJson(String value) { @@ -353,12 +308,12 @@ enum GeoResource { } extension GeoResourceExt on GeoResource { - String get value { + String get configKey { return switch (this) { GeoResource.MMDB => 'mmdb', GeoResource.ASN => 'asn', - GeoResource.GEOIP => 'geo-ip', - GeoResource.GEOSITE => 'geo-site', + GeoResource.GEOIP => 'geoip', + GeoResource.GEOSITE => 'geosite', }; } diff --git a/lib/l10n/intl/messages_en.dart b/lib/l10n/intl/messages_en.dart index c4da298db0..65bc0e5c46 100644 --- a/lib/l10n/intl/messages_en.dart +++ b/lib/l10n/intl/messages_en.dart @@ -37,7 +37,7 @@ class MessageLookup extends MessageLookupByLibrary { static String m6(label) => "Current ${label} already exists"; - static String m7(name) => "${name} skipped"; + static String m7(name) => "${name} is already up to date"; static String m8(name) => "${name} updated"; @@ -272,6 +272,10 @@ class MessageLookup extends MessageLookupByLibrary { "core": MessageLookupByLibrary.simpleMessage("Core"), "coreStatus": MessageLookupByLibrary.simpleMessage("Core status"), "country": MessageLookupByLibrary.simpleMessage("Country"), + "crashDetected": MessageLookupByLibrary.simpleMessage("Crash detected"), + "crashDetectedTip": MessageLookupByLibrary.simpleMessage( + "The app crashed during the previous run. To prevent repeated crashes, the current profile has been cleared and automatic configuration setup was skipped.", + ), "crashTest": MessageLookupByLibrary.simpleMessage("Crash test"), "crashlytics": MessageLookupByLibrary.simpleMessage("Crash Analysis"), "crashlyticsTip": MessageLookupByLibrary.simpleMessage( diff --git a/lib/l10n/intl/messages_ja.dart b/lib/l10n/intl/messages_ja.dart index ceac8a469b..78369a0450 100644 --- a/lib/l10n/intl/messages_ja.dart +++ b/lib/l10n/intl/messages_ja.dart @@ -34,7 +34,7 @@ class MessageLookup extends MessageLookupByLibrary { static String m6(label) => "็พๅœจใฎ${label}ใฏๆ—ขใซๅญ˜ๅœจใ—ใฆใ„ใพใ™"; - static String m7(name) => "${name} ใ‚นใ‚ญใƒƒใƒ—ๆธˆใฟ"; + static String m7(name) => "${name} ใฏใ™ใงใซๆœ€ๆ–ฐใงใ™"; static String m8(name) => "${name} ๆ›ดๆ–ฐๆธˆใฟ"; @@ -212,6 +212,10 @@ class MessageLookup extends MessageLookupByLibrary { "core": MessageLookupByLibrary.simpleMessage("ใ‚ณใ‚ข"), "coreStatus": MessageLookupByLibrary.simpleMessage("ใ‚ณใ‚ขใ‚นใƒ†ใƒผใ‚ฟใ‚น"), "country": MessageLookupByLibrary.simpleMessage("ๅ›ฝ"), + "crashDetected": MessageLookupByLibrary.simpleMessage("ใ‚ฏใƒฉใƒƒใ‚ทใƒฅใ‚’ๆคœๅ‡บใ—ใพใ—ใŸ"), + "crashDetectedTip": MessageLookupByLibrary.simpleMessage( + "ๅ‰ๅ›žใฎๅฎŸ่กŒไธญใซใ‚ขใƒ—ใƒชใŒใ‚ฏใƒฉใƒƒใ‚ทใƒฅใ—ใพใ—ใŸใ€‚ใ‚ฏใƒฉใƒƒใ‚ทใƒฅใฎ็นฐใ‚Š่ฟ”ใ—ใ‚’้˜ฒใใŸใ‚ใ€็พๅœจใฎใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’่งฃ้™คใ—ใ€่จญๅฎšใฎ่‡ชๅ‹•ใ‚ปใƒƒใƒˆใ‚ขใƒƒใƒ—ใ‚’ใ‚นใ‚ญใƒƒใƒ—ใ—ใพใ—ใŸใ€‚", + ), "crashTest": MessageLookupByLibrary.simpleMessage("ใ‚ฏใƒฉใƒƒใ‚ทใƒฅใƒ†ใ‚นใƒˆ"), "crashlytics": MessageLookupByLibrary.simpleMessage("ใ‚ฏใƒฉใƒƒใ‚ทใƒฅๅˆ†ๆž"), "crashlyticsTip": MessageLookupByLibrary.simpleMessage( diff --git a/lib/l10n/intl/messages_ru.dart b/lib/l10n/intl/messages_ru.dart index adf4d6de1e..e6c997e6b0 100644 --- a/lib/l10n/intl/messages_ru.dart +++ b/lib/l10n/intl/messages_ru.dart @@ -36,7 +36,7 @@ class MessageLookup extends MessageLookupByLibrary { static String m6(label) => "ะขะตะบัƒั‰ะธะน ${label} ัƒะถะต ััƒั‰ะตัั‚ะฒัƒะตั‚"; - static String m7(name) => "${name} ะฟั€ะพะฟัƒั‰ะตะฝะพ"; + static String m7(name) => "ะ”ะปั ${name} ัƒะถะต ัƒัั‚ะฐะฝะพะฒะปะตะฝะฐ ะฟะพัะปะตะดะฝัั ะฒะตั€ัะธั"; static String m8(name) => "${name} ะพะฑะฝะพะฒะปะตะฝะพ"; @@ -279,6 +279,10 @@ class MessageLookup extends MessageLookupByLibrary { "core": MessageLookupByLibrary.simpleMessage("ะฏะดั€ะพ"), "coreStatus": MessageLookupByLibrary.simpleMessage("ะžัะฝะพะฒะฝะพะน ัั‚ะฐั‚ัƒั"), "country": MessageLookupByLibrary.simpleMessage("ะกั‚ั€ะฐะฝะฐ"), + "crashDetected": MessageLookupByLibrary.simpleMessage("ะžะฑะฝะฐั€ัƒะถะตะฝ ัะฑะพะน"), + "crashDetectedTip": MessageLookupByLibrary.simpleMessage( + "ะ’ะพ ะฒั€ะตะผั ะฟั€ะตะดั‹ะดัƒั‰ะตะณะพ ะทะฐะฟัƒัะบะฐ ะฟั€ะพะธะทะพัˆั‘ะป ัะฑะพะน ะฟั€ะธะปะพะถะตะฝะธั. ะงั‚ะพะฑั‹ ะฟั€ะตะดะพั‚ะฒั€ะฐั‚ะธั‚ัŒ ะฟะพะฒั‚ะพั€ะฝั‹ะน ัะฑะพะน, ั‚ะตะบัƒั‰ะธะน ะฟั€ะพั„ะธะปัŒ ะฑั‹ะป ัะฑั€ะพัˆะตะฝ, ะฐ ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะฐั ะฝะฐัั‚ั€ะพะนะบะฐ ะบะพะฝั„ะธะณัƒั€ะฐั†ะธะธ ะฟั€ะพะฟัƒั‰ะตะฝะฐ.", + ), "crashTest": MessageLookupByLibrary.simpleMessage("ะขะตัั‚ ะฝะฐ ัะฑะพะธ"), "crashlytics": MessageLookupByLibrary.simpleMessage("ะะฝะฐะปะธะท ัะฑะพะตะฒ"), "crashlyticsTip": MessageLookupByLibrary.simpleMessage( diff --git a/lib/l10n/intl/messages_zh_CN.dart b/lib/l10n/intl/messages_zh_CN.dart index e198261265..4d5d66d271 100644 --- a/lib/l10n/intl/messages_zh_CN.dart +++ b/lib/l10n/intl/messages_zh_CN.dart @@ -34,7 +34,7 @@ class MessageLookup extends MessageLookupByLibrary { static String m6(label) => "${label}ๅฝ“ๅ‰ๅทฒๅญ˜ๅœจ"; - static String m7(name) => "${name} ๅทฒ่ทณ่ฟ‡"; + static String m7(name) => "${name} ๅทฒๆ˜ฏๆœ€ๆ–ฐ็‰ˆๆœฌ"; static String m8(name) => "${name} ๅทฒๆ›ดๆ–ฐ"; @@ -190,6 +190,10 @@ class MessageLookup extends MessageLookupByLibrary { "core": MessageLookupByLibrary.simpleMessage("ๅ†…ๆ ธ"), "coreStatus": MessageLookupByLibrary.simpleMessage("ๆ ธๅฟƒ็Šถๆ€"), "country": MessageLookupByLibrary.simpleMessage("ๅŒบๅŸŸ"), + "crashDetected": MessageLookupByLibrary.simpleMessage("ๆฃ€ๆต‹ๅˆฐๅดฉๆบƒ"), + "crashDetectedTip": MessageLookupByLibrary.simpleMessage( + "ๆฃ€ๆต‹ๅˆฐๅบ”็”จไธŠๆฌก่ฟ่กŒๅ‘็”Ÿๅดฉๆบƒใ€‚ไธบ้ฟๅ…้‡ๅคๅดฉๆบƒ๏ผŒๅทฒๆธ…้™คๅฝ“ๅ‰้…็ฝฎ้€‰ๆ‹ฉ๏ผŒๅนถ่ทณ่ฟ‡ๆœฌๆฌก่‡ชๅŠจ้…็ฝฎใ€‚", + ), "crashTest": MessageLookupByLibrary.simpleMessage("ๅดฉๆบƒๆต‹่ฏ•"), "crashlytics": MessageLookupByLibrary.simpleMessage("ๅดฉๆบƒๅˆ†ๆž"), "crashlyticsTip": MessageLookupByLibrary.simpleMessage( diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 3b96b136b2..a431c4fe66 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -2429,6 +2429,26 @@ class AppLocalizations { return Intl.message('Crash test', name: 'crashTest', desc: '', args: []); } + /// `Crash detected` + String get crashDetected { + return Intl.message( + 'Crash detected', + name: 'crashDetected', + desc: '', + args: [], + ); + } + + /// `The app crashed during the previous run. To prevent repeated crashes, the current profile has been cleared and automatic configuration setup was skipped.` + String get crashDetectedTip { + return Intl.message( + 'The app crashed during the previous run. To prevent repeated crashes, the current profile has been cleared and automatic configuration setup was skipped.', + name: 'crashDetectedTip', + desc: '', + args: [], + ); + } + /// `Clear Data` String get clearData { return Intl.message('Clear Data', name: 'clearData', desc: '', args: []); @@ -4454,10 +4474,10 @@ class AppLocalizations { ); } - /// `{name} skipped` + /// `{name} is already up to date` String geoSkipped(Object name) { return Intl.message( - '$name skipped', + '$name is already up to date', name: 'geoSkipped', desc: '', args: [name], diff --git a/lib/manager/android_manager.dart b/lib/manager/android_manager.dart index bba334a88a..240239d87a 100644 --- a/lib/manager/android_manager.dart +++ b/lib/manager/android_manager.dart @@ -53,14 +53,6 @@ class _AndroidContainerState extends ConsumerState super.onServiceEvent(event); } - @override - void onServiceCrash(String message) { - coreEventManager.sendEvent( - CoreEvent(type: CoreEventType.crash, data: message), - ); - super.onServiceCrash(message); - } - @override Widget build(BuildContext context) { return widget.child; diff --git a/lib/manager/app_manager.dart b/lib/manager/app_manager.dart index 6f8377c6d3..f8ed10c01d 100644 --- a/lib/manager/app_manager.dart +++ b/lib/manager/app_manager.dart @@ -87,9 +87,6 @@ class _AppStateManagerState extends ConsumerState WidgetsBinding.instance.addPostFrameCallback((_) { final ref = globalState.container; ref.read(setupActionProvider.notifier).tryCheckIp(); - if (system.isAndroid) { - ref.read(coreActionProvider.notifier).tryStartCore(); - } }); } } @@ -128,7 +125,7 @@ class AppEnvManager extends StatelessWidget { } if (globalState.isPre) { return Banner( - message: 'PRE', + message: globalState.appEnv.toUpperCase(), location: BannerLocation.topEnd, child: child, ); diff --git a/lib/manager/core_manager.dart b/lib/manager/core_manager.dart index 0ae0927a71..1879f767a6 100644 --- a/lib/manager/core_manager.dart +++ b/lib/manager/core_manager.dart @@ -14,8 +14,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; class CoreManager extends ConsumerStatefulWidget { final Widget child; + final CoreController controller; - const CoreManager({super.key, required this.child}); + CoreManager({super.key, required this.child, CoreController? controller}) + : controller = controller ?? coreController; @override ConsumerState createState() => _CoreContainerState(); @@ -49,15 +51,15 @@ class _CoreContainerState extends ConsumerState next, ) { if (next) { - coreController.startLog(); + widget.controller.startLog(); } else { - coreController.stopLog(); + widget.controller.stopLog(); } }, fireImmediately: true); } @override - Future dispose() async { + void dispose() { coreEventManager.removeListener(this); super.dispose(); } @@ -92,7 +94,7 @@ class _CoreContainerState extends ConsumerState final ref = globalState.container; ref .read(providersProvider.notifier) - .setProvider(await coreController.getExternalProvider(providerName)); + .setProvider(await widget.controller.getExternalProvider(providerName)); debouncer.call(FunctionTag.loadedProvider, () async { ref.read(proxiesActionProvider.notifier).updateGroupsDebounce(); }, duration: const Duration(milliseconds: 5000)); @@ -108,7 +110,6 @@ class _CoreContainerState extends ConsumerState if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) { context.showNotifier(message); } - await coreController.shutdown(false); super.onCrash(message); } diff --git a/lib/manager/hotkey_manager.dart b/lib/manager/hotkey_manager.dart index f70f0e531f..661cf90887 100644 --- a/lib/manager/hotkey_manager.dart +++ b/lib/manager/hotkey_manager.dart @@ -37,7 +37,7 @@ class _HotKeyManagerState extends ConsumerState { case HotAction.mode: commonAction.updateMode(); case HotAction.start: - commonAction.updateStart(); + commonAction.toggleRunning(); case HotAction.view: systemAction.updateVisible(); case HotAction.proxy: diff --git a/lib/manager/tile_manager.dart b/lib/manager/tile_manager.dart index 29248741a1..d0d0a6fdca 100644 --- a/lib/manager/tile_manager.dart +++ b/lib/manager/tile_manager.dart @@ -1,5 +1,5 @@ import 'package:fl_clash/common/app_localizations.dart'; -import 'package:fl_clash/core/controller.dart'; +import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/plugins/app.dart'; import 'package:fl_clash/plugins/tile.dart'; import 'package:fl_clash/providers/providers.dart'; @@ -25,10 +25,10 @@ class _TileContainerState extends ConsumerState with TileListener { @override Future onStart() async { - if (isStart && coreController.isCompleted) { + if (isStart && ref.read(coreStatusProvider) == CoreStatus.connected) { return; } - ref.read(setupActionProvider.notifier).updateStatus(true); + ref.read(setupActionProvider.notifier).setRunning(true); app?.tip(currentAppLocalizations.startVpn); super.onStart(); } @@ -38,7 +38,7 @@ class _TileContainerState extends ConsumerState with TileListener { if (!isStart) { return; } - ref.read(setupActionProvider.notifier).updateStatus(false); + ref.read(setupActionProvider.notifier).setRunning(false); app?.tip(currentAppLocalizations.stopVpn); super.onStop(); } diff --git a/lib/manager/vpn_manager.dart b/lib/manager/vpn_manager.dart index 4beec0b777..3c3d58aaf2 100644 --- a/lib/manager/vpn_manager.dart +++ b/lib/manager/vpn_manager.dart @@ -40,8 +40,8 @@ class _VpnContainerState extends ConsumerState { actionText: currentAppLocalizations.restart, action: () async { final setupAction = ref.read(setupActionProvider.notifier); - await setupAction.handleStop(); - await setupAction.updateStatus(true); + await setupAction.setRunning(false); + await setupAction.setRunning(true); }, ), ); diff --git a/lib/models/app.dart b/lib/models/app.dart index 6ea055a311..a10f83d157 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -10,7 +10,7 @@ part 'generated/app.freezed.dart'; typedef DelayMap = Map>; -@freezed +@Freezed(toStringOverride: false) abstract class AppState with _$AppState { const factory AppState({ @Default(false) bool isInit, @@ -32,7 +32,8 @@ abstract class AppState with _$AppState { required FixedList logs, required FixedList traffics, required Traffic totalTraffic, - @Default(false) bool realTunEnable, + @Default(TunAuthorizationState.none) + TunAuthorizationState authorizedTunEnable, @Default(false) bool loading, required SystemUiOverlayStyle systemUiOverlayStyle, @Default(CoreStatus.connecting) CoreStatus coreStatus, diff --git a/lib/models/clash_config.dart b/lib/models/clash_config.dart index 19fe746e14..4ee4a8654a 100644 --- a/lib/models/clash_config.dart +++ b/lib/models/clash_config.dart @@ -518,7 +518,7 @@ abstract class ClashConfig with _$ClashConfig { extension GeoResourceUrlMapExt on Map { Map get raw => - map((key, value) => MapEntry(key.value, value)); + map((key, value) => MapEntry(key.configKey, value)); } Map _geoXUrlFromJson(Map? json) { diff --git a/lib/models/common.dart b/lib/models/common.dart index aa88a7ff51..e5946161c2 100644 --- a/lib/models/common.dart +++ b/lib/models/common.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:collection/collection.dart'; import 'package:fl_clash/common/common.dart'; @@ -218,29 +220,138 @@ extension TrackerInfosStateExt on TrackerInfosState { } const defaultDavFileName = 'backup.zip'; +const _davPasswordFormatVersion = 'v1'; +const _davPasswordNonceLength = 16; +const _davPasswordObfuscationMask = [ + 0x9d, + 0x42, + 0xe7, + 0x1b, + 0x68, + 0xb4, + 0x35, + 0xca, + 0x7f, + 0x20, + 0xd1, + 0x56, + 0x83, + 0xfa, + 0x0c, + 0xa9, +]; + +// This only prevents accidental plain-text disclosure. It is deliberately not +// a security boundary against reverse engineering or same-user access. +String _encodeDavPassword(String password) { + if (password.isEmpty) { + return ''; + } + final random = Random.secure(); + final nonce = List.generate( + _davPasswordNonceLength, + (_) => random.nextInt(256), + growable: false, + ); + final passwordBytes = utf8.encode(password); + final obfuscated = List.generate( + passwordBytes.length, + (index) => + passwordBytes[index] ^ + nonce[index % nonce.length] ^ + _davPasswordObfuscationMask[index % _davPasswordObfuscationMask.length], + growable: false, + ); + return [ + _davPasswordFormatVersion, + base64UrlEncode(nonce), + base64UrlEncode(obfuscated), + ].join('.'); +} + +String _decodeDavPassword(String? value) { + if (value == null || value.isEmpty) { + return ''; + } + final parts = value.split('.'); + if (parts.length != 3 || parts[0] != _davPasswordFormatVersion) { + return value; + } + try { + final nonce = base64Url.decode(parts[1]); + final obfuscated = base64Url.decode(parts[2]); + if (nonce.length != _davPasswordNonceLength) { + return ''; + } + final passwordBytes = List.generate( + obfuscated.length, + (index) => + obfuscated[index] ^ + nonce[index % nonce.length] ^ + _davPasswordObfuscationMask[index % + _davPasswordObfuscationMask.length], + growable: false, + ); + return utf8.decode(passwordBytes); + } on FormatException { + return ''; + } +} -@freezed +@Freezed(toStringOverride: false) abstract class DAVProps with _$DAVProps { + const DAVProps._(); + const factory DAVProps({ required String uri, required String user, - required String password, + @JsonKey(fromJson: _decodeDavPassword, toJson: _encodeDavPassword) + @Default('') + String password, @Default(defaultDavFileName) String fileName, }) = _DAVProps; factory DAVProps.fromJson(Map json) => _$DAVPropsFromJson(json); + + @override + String toString() => + 'DAVProps(uri: $uri, user: $user, password: ***, fileName: $fileName)'; } @freezed abstract class FileInfo with _$FileInfo { - const factory FileInfo({required int size, required DateTime lastModified}) = + const factory FileInfo({required int size, DateTime? lastModified}) = _FileInfo; } +extension FileInfoFileExt on File { + Future getFileInfo() async { + if (!await exists()) { + return null; + } + final size = await length(); + final lastModified = await _getValidLastModified(); + return FileInfo(size: size, lastModified: lastModified); + } + + Future _getValidLastModified() async { + try { + final value = await lastModified(); + return value.year > 1970 ? value : null; + } on FileSystemException { + return null; + } + } +} + extension FileInfoExt on FileInfo { - String getDesc(BuildContext context) => - '${size.traffic.show} ยท ${lastModified.getLastUpdateTimeDesc(context)}'; + String getDesc(BuildContext context) { + final lastModifiedDesc = + lastModified?.getLastUpdateTimeDesc(context) ?? + context.appLocalizations.unknown; + return '${size.traffic.show} ยท $lastModifiedDesc'; + } } @freezed diff --git a/lib/models/core.dart b/lib/models/core.dart index 7cd12576c7..3c4788ed1b 100644 --- a/lib/models/core.dart +++ b/lib/models/core.dart @@ -168,17 +168,6 @@ extension ExternalProviderExt on ExternalProvider { String get updatingKey => 'provider_$name'; } -@freezed -abstract class Action with _$Action { - const factory Action({ - required ActionMethod method, - required dynamic data, - required String id, - }) = _Action; - - factory Action.fromJson(Map json) => _$ActionFromJson(json); -} - @freezed abstract class ProxiesData with _$ProxiesData { const factory ProxiesData({ @@ -189,26 +178,3 @@ abstract class ProxiesData with _$ProxiesData { factory ProxiesData.fromJson(Map json) => _$ProxiesDataFromJson(json); } - -@freezed -abstract class ActionResult with _$ActionResult { - const factory ActionResult({ - required ActionMethod method, - required dynamic data, - String? id, - @Default(ResultType.success) ResultType code, - }) = _ActionResult; - - factory ActionResult.fromJson(Map json) => - _$ActionResultFromJson(json); -} - -extension ActionResultExt on ActionResult { - Result get toResult { - if (code == ResultType.success) { - return Result.success(data); - } else { - return Result.error('$data'); - } - } -} diff --git a/lib/models/generated/app.freezed.dart b/lib/models/generated/app.freezed.dart index 4e11773fbf..5e822ae33e 100644 --- a/lib/models/generated/app.freezed.dart +++ b/lib/models/generated/app.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$AppState { - bool get isInit; bool get backBlock; PageLabel get pageLabel; List get packages; int get sortNum; Size get viewSize; double get sideWidth; DelayMap get delayMap; List get groups; int get checkIpNum; Brightness get brightness; int? get runTime; List get providers; String? get localIp; FixedList get requests; int get version; FixedList get logs; FixedList get traffics; Traffic get totalTraffic; bool get realTunEnable; bool get loading; SystemUiOverlayStyle get systemUiOverlayStyle; CoreStatus get coreStatus; + bool get isInit; bool get backBlock; PageLabel get pageLabel; List get packages; int get sortNum; Size get viewSize; double get sideWidth; DelayMap get delayMap; List get groups; int get checkIpNum; Brightness get brightness; int? get runTime; List get providers; String? get localIp; FixedList get requests; int get version; FixedList get logs; FixedList get traffics; Traffic get totalTraffic; TunAuthorizationState get authorizedTunEnable; bool get loading; SystemUiOverlayStyle get systemUiOverlayStyle; CoreStatus get coreStatus; /// Create a copy of AppState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -25,17 +25,13 @@ $AppStateCopyWith get copyWith => _$AppStateCopyWithImpl(thi @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is AppState&&(identical(other.isInit, isInit) || other.isInit == isInit)&&(identical(other.backBlock, backBlock) || other.backBlock == backBlock)&&(identical(other.pageLabel, pageLabel) || other.pageLabel == pageLabel)&&const DeepCollectionEquality().equals(other.packages, packages)&&(identical(other.sortNum, sortNum) || other.sortNum == sortNum)&&(identical(other.viewSize, viewSize) || other.viewSize == viewSize)&&(identical(other.sideWidth, sideWidth) || other.sideWidth == sideWidth)&&const DeepCollectionEquality().equals(other.delayMap, delayMap)&&const DeepCollectionEquality().equals(other.groups, groups)&&(identical(other.checkIpNum, checkIpNum) || other.checkIpNum == checkIpNum)&&(identical(other.brightness, brightness) || other.brightness == brightness)&&(identical(other.runTime, runTime) || other.runTime == runTime)&&const DeepCollectionEquality().equals(other.providers, providers)&&(identical(other.localIp, localIp) || other.localIp == localIp)&&(identical(other.requests, requests) || other.requests == requests)&&(identical(other.version, version) || other.version == version)&&(identical(other.logs, logs) || other.logs == logs)&&(identical(other.traffics, traffics) || other.traffics == traffics)&&(identical(other.totalTraffic, totalTraffic) || other.totalTraffic == totalTraffic)&&(identical(other.realTunEnable, realTunEnable) || other.realTunEnable == realTunEnable)&&(identical(other.loading, loading) || other.loading == loading)&&(identical(other.systemUiOverlayStyle, systemUiOverlayStyle) || other.systemUiOverlayStyle == systemUiOverlayStyle)&&(identical(other.coreStatus, coreStatus) || other.coreStatus == coreStatus)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is AppState&&(identical(other.isInit, isInit) || other.isInit == isInit)&&(identical(other.backBlock, backBlock) || other.backBlock == backBlock)&&(identical(other.pageLabel, pageLabel) || other.pageLabel == pageLabel)&&const DeepCollectionEquality().equals(other.packages, packages)&&(identical(other.sortNum, sortNum) || other.sortNum == sortNum)&&(identical(other.viewSize, viewSize) || other.viewSize == viewSize)&&(identical(other.sideWidth, sideWidth) || other.sideWidth == sideWidth)&&const DeepCollectionEquality().equals(other.delayMap, delayMap)&&const DeepCollectionEquality().equals(other.groups, groups)&&(identical(other.checkIpNum, checkIpNum) || other.checkIpNum == checkIpNum)&&(identical(other.brightness, brightness) || other.brightness == brightness)&&(identical(other.runTime, runTime) || other.runTime == runTime)&&const DeepCollectionEquality().equals(other.providers, providers)&&(identical(other.localIp, localIp) || other.localIp == localIp)&&(identical(other.requests, requests) || other.requests == requests)&&(identical(other.version, version) || other.version == version)&&(identical(other.logs, logs) || other.logs == logs)&&(identical(other.traffics, traffics) || other.traffics == traffics)&&(identical(other.totalTraffic, totalTraffic) || other.totalTraffic == totalTraffic)&&(identical(other.authorizedTunEnable, authorizedTunEnable) || other.authorizedTunEnable == authorizedTunEnable)&&(identical(other.loading, loading) || other.loading == loading)&&(identical(other.systemUiOverlayStyle, systemUiOverlayStyle) || other.systemUiOverlayStyle == systemUiOverlayStyle)&&(identical(other.coreStatus, coreStatus) || other.coreStatus == coreStatus)); } @override -int get hashCode => Object.hashAll([runtimeType,isInit,backBlock,pageLabel,const DeepCollectionEquality().hash(packages),sortNum,viewSize,sideWidth,const DeepCollectionEquality().hash(delayMap),const DeepCollectionEquality().hash(groups),checkIpNum,brightness,runTime,const DeepCollectionEquality().hash(providers),localIp,requests,version,logs,traffics,totalTraffic,realTunEnable,loading,systemUiOverlayStyle,coreStatus]); +int get hashCode => Object.hashAll([runtimeType,isInit,backBlock,pageLabel,const DeepCollectionEquality().hash(packages),sortNum,viewSize,sideWidth,const DeepCollectionEquality().hash(delayMap),const DeepCollectionEquality().hash(groups),checkIpNum,brightness,runTime,const DeepCollectionEquality().hash(providers),localIp,requests,version,logs,traffics,totalTraffic,authorizedTunEnable,loading,systemUiOverlayStyle,coreStatus]); -@override -String toString() { - return 'AppState(isInit: $isInit, backBlock: $backBlock, pageLabel: $pageLabel, packages: $packages, sortNum: $sortNum, viewSize: $viewSize, sideWidth: $sideWidth, delayMap: $delayMap, groups: $groups, checkIpNum: $checkIpNum, brightness: $brightness, runTime: $runTime, providers: $providers, localIp: $localIp, requests: $requests, version: $version, logs: $logs, traffics: $traffics, totalTraffic: $totalTraffic, realTunEnable: $realTunEnable, loading: $loading, systemUiOverlayStyle: $systemUiOverlayStyle, coreStatus: $coreStatus)'; -} } @@ -45,7 +41,7 @@ abstract mixin class $AppStateCopyWith<$Res> { factory $AppStateCopyWith(AppState value, $Res Function(AppState) _then) = _$AppStateCopyWithImpl; @useResult $Res call({ - bool isInit, bool backBlock, PageLabel pageLabel, List packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List groups, int checkIpNum, Brightness brightness, int? runTime, List providers, String? localIp, FixedList requests, int version, FixedList logs, FixedList traffics, Traffic totalTraffic, bool realTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus + bool isInit, bool backBlock, PageLabel pageLabel, List packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List groups, int checkIpNum, Brightness brightness, int? runTime, List providers, String? localIp, FixedList requests, int version, FixedList logs, FixedList traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus }); @@ -62,7 +58,7 @@ class _$AppStateCopyWithImpl<$Res> /// Create a copy of AppState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? isInit = null,Object? backBlock = null,Object? pageLabel = null,Object? packages = null,Object? sortNum = null,Object? viewSize = null,Object? sideWidth = null,Object? delayMap = null,Object? groups = null,Object? checkIpNum = null,Object? brightness = null,Object? runTime = freezed,Object? providers = null,Object? localIp = freezed,Object? requests = null,Object? version = null,Object? logs = null,Object? traffics = null,Object? totalTraffic = null,Object? realTunEnable = null,Object? loading = null,Object? systemUiOverlayStyle = null,Object? coreStatus = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? isInit = null,Object? backBlock = null,Object? pageLabel = null,Object? packages = null,Object? sortNum = null,Object? viewSize = null,Object? sideWidth = null,Object? delayMap = null,Object? groups = null,Object? checkIpNum = null,Object? brightness = null,Object? runTime = freezed,Object? providers = null,Object? localIp = freezed,Object? requests = null,Object? version = null,Object? logs = null,Object? traffics = null,Object? totalTraffic = null,Object? authorizedTunEnable = null,Object? loading = null,Object? systemUiOverlayStyle = null,Object? coreStatus = null,}) { return _then(_self.copyWith( isInit: null == isInit ? _self.isInit : isInit // ignore: cast_nullable_to_non_nullable as bool,backBlock: null == backBlock ? _self.backBlock : backBlock // ignore: cast_nullable_to_non_nullable @@ -83,8 +79,8 @@ as FixedList,version: null == version ? _self.version : version // as int,logs: null == logs ? _self.logs : logs // ignore: cast_nullable_to_non_nullable as FixedList,traffics: null == traffics ? _self.traffics : traffics // ignore: cast_nullable_to_non_nullable as FixedList,totalTraffic: null == totalTraffic ? _self.totalTraffic : totalTraffic // ignore: cast_nullable_to_non_nullable -as Traffic,realTunEnable: null == realTunEnable ? _self.realTunEnable : realTunEnable // ignore: cast_nullable_to_non_nullable -as bool,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable +as Traffic,authorizedTunEnable: null == authorizedTunEnable ? _self.authorizedTunEnable : authorizedTunEnable // ignore: cast_nullable_to_non_nullable +as TunAuthorizationState,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable as bool,systemUiOverlayStyle: null == systemUiOverlayStyle ? _self.systemUiOverlayStyle : systemUiOverlayStyle // ignore: cast_nullable_to_non_nullable as SystemUiOverlayStyle,coreStatus: null == coreStatus ? _self.coreStatus : coreStatus // ignore: cast_nullable_to_non_nullable as CoreStatus, @@ -181,10 +177,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( bool isInit, bool backBlock, PageLabel pageLabel, List packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List groups, int checkIpNum, Brightness brightness, int? runTime, List providers, String? localIp, FixedList requests, int version, FixedList logs, FixedList traffics, Traffic totalTraffic, bool realTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isInit, bool backBlock, PageLabel pageLabel, List packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List groups, int checkIpNum, Brightness brightness, int? runTime, List providers, String? localIp, FixedList requests, int version, FixedList logs, FixedList traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _AppState() when $default != null: -return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_that.sortNum,_that.viewSize,_that.sideWidth,_that.delayMap,_that.groups,_that.checkIpNum,_that.brightness,_that.runTime,_that.providers,_that.localIp,_that.requests,_that.version,_that.logs,_that.traffics,_that.totalTraffic,_that.realTunEnable,_that.loading,_that.systemUiOverlayStyle,_that.coreStatus);case _: +return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_that.sortNum,_that.viewSize,_that.sideWidth,_that.delayMap,_that.groups,_that.checkIpNum,_that.brightness,_that.runTime,_that.providers,_that.localIp,_that.requests,_that.version,_that.logs,_that.traffics,_that.totalTraffic,_that.authorizedTunEnable,_that.loading,_that.systemUiOverlayStyle,_that.coreStatus);case _: return orElse(); } @@ -202,10 +198,10 @@ return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_tha /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( bool isInit, bool backBlock, PageLabel pageLabel, List packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List groups, int checkIpNum, Brightness brightness, int? runTime, List providers, String? localIp, FixedList requests, int version, FixedList logs, FixedList traffics, Traffic totalTraffic, bool realTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( bool isInit, bool backBlock, PageLabel pageLabel, List packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List groups, int checkIpNum, Brightness brightness, int? runTime, List providers, String? localIp, FixedList requests, int version, FixedList logs, FixedList traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus) $default,) {final _that = this; switch (_that) { case _AppState(): -return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_that.sortNum,_that.viewSize,_that.sideWidth,_that.delayMap,_that.groups,_that.checkIpNum,_that.brightness,_that.runTime,_that.providers,_that.localIp,_that.requests,_that.version,_that.logs,_that.traffics,_that.totalTraffic,_that.realTunEnable,_that.loading,_that.systemUiOverlayStyle,_that.coreStatus);case _: +return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_that.sortNum,_that.viewSize,_that.sideWidth,_that.delayMap,_that.groups,_that.checkIpNum,_that.brightness,_that.runTime,_that.providers,_that.localIp,_that.requests,_that.version,_that.logs,_that.traffics,_that.totalTraffic,_that.authorizedTunEnable,_that.loading,_that.systemUiOverlayStyle,_that.coreStatus);case _: throw StateError('Unexpected subclass'); } @@ -222,10 +218,10 @@ return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_tha /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isInit, bool backBlock, PageLabel pageLabel, List packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List groups, int checkIpNum, Brightness brightness, int? runTime, List providers, String? localIp, FixedList requests, int version, FixedList logs, FixedList traffics, Traffic totalTraffic, bool realTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isInit, bool backBlock, PageLabel pageLabel, List packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List groups, int checkIpNum, Brightness brightness, int? runTime, List providers, String? localIp, FixedList requests, int version, FixedList logs, FixedList traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus)? $default,) {final _that = this; switch (_that) { case _AppState() when $default != null: -return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_that.sortNum,_that.viewSize,_that.sideWidth,_that.delayMap,_that.groups,_that.checkIpNum,_that.brightness,_that.runTime,_that.providers,_that.localIp,_that.requests,_that.version,_that.logs,_that.traffics,_that.totalTraffic,_that.realTunEnable,_that.loading,_that.systemUiOverlayStyle,_that.coreStatus);case _: +return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_that.sortNum,_that.viewSize,_that.sideWidth,_that.delayMap,_that.groups,_that.checkIpNum,_that.brightness,_that.runTime,_that.providers,_that.localIp,_that.requests,_that.version,_that.logs,_that.traffics,_that.totalTraffic,_that.authorizedTunEnable,_that.loading,_that.systemUiOverlayStyle,_that.coreStatus);case _: return null; } @@ -237,7 +233,7 @@ return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_tha class _AppState implements AppState { - const _AppState({this.isInit = false, this.backBlock = false, this.pageLabel = PageLabel.dashboard, final List packages = const [], this.sortNum = 0, required this.viewSize, this.sideWidth = 0, final DelayMap delayMap = const {}, final List groups = const [], this.checkIpNum = 0, required this.brightness, this.runTime, final List providers = const [], this.localIp, required this.requests, required this.version, required this.logs, required this.traffics, required this.totalTraffic, this.realTunEnable = false, this.loading = false, required this.systemUiOverlayStyle, this.coreStatus = CoreStatus.connecting}): _packages = packages,_delayMap = delayMap,_groups = groups,_providers = providers; + const _AppState({this.isInit = false, this.backBlock = false, this.pageLabel = PageLabel.dashboard, final List packages = const [], this.sortNum = 0, required this.viewSize, this.sideWidth = 0, final DelayMap delayMap = const {}, final List groups = const [], this.checkIpNum = 0, required this.brightness, this.runTime, final List providers = const [], this.localIp, required this.requests, required this.version, required this.logs, required this.traffics, required this.totalTraffic, this.authorizedTunEnable = TunAuthorizationState.none, this.loading = false, required this.systemUiOverlayStyle, this.coreStatus = CoreStatus.connecting}): _packages = packages,_delayMap = delayMap,_groups = groups,_providers = providers; @override@JsonKey() final bool isInit; @@ -283,7 +279,7 @@ class _AppState implements AppState { @override final FixedList logs; @override final FixedList traffics; @override final Traffic totalTraffic; -@override@JsonKey() final bool realTunEnable; +@override@JsonKey() final TunAuthorizationState authorizedTunEnable; @override@JsonKey() final bool loading; @override final SystemUiOverlayStyle systemUiOverlayStyle; @override@JsonKey() final CoreStatus coreStatus; @@ -298,17 +294,13 @@ _$AppStateCopyWith<_AppState> get copyWith => __$AppStateCopyWithImpl<_AppState> @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppState&&(identical(other.isInit, isInit) || other.isInit == isInit)&&(identical(other.backBlock, backBlock) || other.backBlock == backBlock)&&(identical(other.pageLabel, pageLabel) || other.pageLabel == pageLabel)&&const DeepCollectionEquality().equals(other._packages, _packages)&&(identical(other.sortNum, sortNum) || other.sortNum == sortNum)&&(identical(other.viewSize, viewSize) || other.viewSize == viewSize)&&(identical(other.sideWidth, sideWidth) || other.sideWidth == sideWidth)&&const DeepCollectionEquality().equals(other._delayMap, _delayMap)&&const DeepCollectionEquality().equals(other._groups, _groups)&&(identical(other.checkIpNum, checkIpNum) || other.checkIpNum == checkIpNum)&&(identical(other.brightness, brightness) || other.brightness == brightness)&&(identical(other.runTime, runTime) || other.runTime == runTime)&&const DeepCollectionEquality().equals(other._providers, _providers)&&(identical(other.localIp, localIp) || other.localIp == localIp)&&(identical(other.requests, requests) || other.requests == requests)&&(identical(other.version, version) || other.version == version)&&(identical(other.logs, logs) || other.logs == logs)&&(identical(other.traffics, traffics) || other.traffics == traffics)&&(identical(other.totalTraffic, totalTraffic) || other.totalTraffic == totalTraffic)&&(identical(other.realTunEnable, realTunEnable) || other.realTunEnable == realTunEnable)&&(identical(other.loading, loading) || other.loading == loading)&&(identical(other.systemUiOverlayStyle, systemUiOverlayStyle) || other.systemUiOverlayStyle == systemUiOverlayStyle)&&(identical(other.coreStatus, coreStatus) || other.coreStatus == coreStatus)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppState&&(identical(other.isInit, isInit) || other.isInit == isInit)&&(identical(other.backBlock, backBlock) || other.backBlock == backBlock)&&(identical(other.pageLabel, pageLabel) || other.pageLabel == pageLabel)&&const DeepCollectionEquality().equals(other._packages, _packages)&&(identical(other.sortNum, sortNum) || other.sortNum == sortNum)&&(identical(other.viewSize, viewSize) || other.viewSize == viewSize)&&(identical(other.sideWidth, sideWidth) || other.sideWidth == sideWidth)&&const DeepCollectionEquality().equals(other._delayMap, _delayMap)&&const DeepCollectionEquality().equals(other._groups, _groups)&&(identical(other.checkIpNum, checkIpNum) || other.checkIpNum == checkIpNum)&&(identical(other.brightness, brightness) || other.brightness == brightness)&&(identical(other.runTime, runTime) || other.runTime == runTime)&&const DeepCollectionEquality().equals(other._providers, _providers)&&(identical(other.localIp, localIp) || other.localIp == localIp)&&(identical(other.requests, requests) || other.requests == requests)&&(identical(other.version, version) || other.version == version)&&(identical(other.logs, logs) || other.logs == logs)&&(identical(other.traffics, traffics) || other.traffics == traffics)&&(identical(other.totalTraffic, totalTraffic) || other.totalTraffic == totalTraffic)&&(identical(other.authorizedTunEnable, authorizedTunEnable) || other.authorizedTunEnable == authorizedTunEnable)&&(identical(other.loading, loading) || other.loading == loading)&&(identical(other.systemUiOverlayStyle, systemUiOverlayStyle) || other.systemUiOverlayStyle == systemUiOverlayStyle)&&(identical(other.coreStatus, coreStatus) || other.coreStatus == coreStatus)); } @override -int get hashCode => Object.hashAll([runtimeType,isInit,backBlock,pageLabel,const DeepCollectionEquality().hash(_packages),sortNum,viewSize,sideWidth,const DeepCollectionEquality().hash(_delayMap),const DeepCollectionEquality().hash(_groups),checkIpNum,brightness,runTime,const DeepCollectionEquality().hash(_providers),localIp,requests,version,logs,traffics,totalTraffic,realTunEnable,loading,systemUiOverlayStyle,coreStatus]); +int get hashCode => Object.hashAll([runtimeType,isInit,backBlock,pageLabel,const DeepCollectionEquality().hash(_packages),sortNum,viewSize,sideWidth,const DeepCollectionEquality().hash(_delayMap),const DeepCollectionEquality().hash(_groups),checkIpNum,brightness,runTime,const DeepCollectionEquality().hash(_providers),localIp,requests,version,logs,traffics,totalTraffic,authorizedTunEnable,loading,systemUiOverlayStyle,coreStatus]); -@override -String toString() { - return 'AppState(isInit: $isInit, backBlock: $backBlock, pageLabel: $pageLabel, packages: $packages, sortNum: $sortNum, viewSize: $viewSize, sideWidth: $sideWidth, delayMap: $delayMap, groups: $groups, checkIpNum: $checkIpNum, brightness: $brightness, runTime: $runTime, providers: $providers, localIp: $localIp, requests: $requests, version: $version, logs: $logs, traffics: $traffics, totalTraffic: $totalTraffic, realTunEnable: $realTunEnable, loading: $loading, systemUiOverlayStyle: $systemUiOverlayStyle, coreStatus: $coreStatus)'; -} } @@ -318,7 +310,7 @@ abstract mixin class _$AppStateCopyWith<$Res> implements $AppStateCopyWith<$Res> factory _$AppStateCopyWith(_AppState value, $Res Function(_AppState) _then) = __$AppStateCopyWithImpl; @override @useResult $Res call({ - bool isInit, bool backBlock, PageLabel pageLabel, List packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List groups, int checkIpNum, Brightness brightness, int? runTime, List providers, String? localIp, FixedList requests, int version, FixedList logs, FixedList traffics, Traffic totalTraffic, bool realTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus + bool isInit, bool backBlock, PageLabel pageLabel, List packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List groups, int checkIpNum, Brightness brightness, int? runTime, List providers, String? localIp, FixedList requests, int version, FixedList logs, FixedList traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus }); @@ -335,7 +327,7 @@ class __$AppStateCopyWithImpl<$Res> /// Create a copy of AppState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? isInit = null,Object? backBlock = null,Object? pageLabel = null,Object? packages = null,Object? sortNum = null,Object? viewSize = null,Object? sideWidth = null,Object? delayMap = null,Object? groups = null,Object? checkIpNum = null,Object? brightness = null,Object? runTime = freezed,Object? providers = null,Object? localIp = freezed,Object? requests = null,Object? version = null,Object? logs = null,Object? traffics = null,Object? totalTraffic = null,Object? realTunEnable = null,Object? loading = null,Object? systemUiOverlayStyle = null,Object? coreStatus = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? isInit = null,Object? backBlock = null,Object? pageLabel = null,Object? packages = null,Object? sortNum = null,Object? viewSize = null,Object? sideWidth = null,Object? delayMap = null,Object? groups = null,Object? checkIpNum = null,Object? brightness = null,Object? runTime = freezed,Object? providers = null,Object? localIp = freezed,Object? requests = null,Object? version = null,Object? logs = null,Object? traffics = null,Object? totalTraffic = null,Object? authorizedTunEnable = null,Object? loading = null,Object? systemUiOverlayStyle = null,Object? coreStatus = null,}) { return _then(_AppState( isInit: null == isInit ? _self.isInit : isInit // ignore: cast_nullable_to_non_nullable as bool,backBlock: null == backBlock ? _self.backBlock : backBlock // ignore: cast_nullable_to_non_nullable @@ -356,8 +348,8 @@ as FixedList,version: null == version ? _self.version : version // as int,logs: null == logs ? _self.logs : logs // ignore: cast_nullable_to_non_nullable as FixedList,traffics: null == traffics ? _self.traffics : traffics // ignore: cast_nullable_to_non_nullable as FixedList,totalTraffic: null == totalTraffic ? _self.totalTraffic : totalTraffic // ignore: cast_nullable_to_non_nullable -as Traffic,realTunEnable: null == realTunEnable ? _self.realTunEnable : realTunEnable // ignore: cast_nullable_to_non_nullable -as bool,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable +as Traffic,authorizedTunEnable: null == authorizedTunEnable ? _self.authorizedTunEnable : authorizedTunEnable // ignore: cast_nullable_to_non_nullable +as TunAuthorizationState,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable as bool,systemUiOverlayStyle: null == systemUiOverlayStyle ? _self.systemUiOverlayStyle : systemUiOverlayStyle // ignore: cast_nullable_to_non_nullable as SystemUiOverlayStyle,coreStatus: null == coreStatus ? _self.coreStatus : coreStatus // ignore: cast_nullable_to_non_nullable as CoreStatus, diff --git a/lib/models/generated/common.freezed.dart b/lib/models/generated/common.freezed.dart index e9af61c650..c41c215392 100644 --- a/lib/models/generated/common.freezed.dart +++ b/lib/models/generated/common.freezed.dart @@ -1208,7 +1208,6 @@ $MetadataCopyWith<$Res> get metadata { /// @nodoc mixin _$Log { -// @JsonKey(fromJson: _logId) required String id, @JsonKey(name: 'LogLevel') LogLevel get logLevel;@JsonKey(name: 'Payload') String get payload;@JsonKey(fromJson: _logDateTime) String get dateTime; /// Create a copy of Log /// with the given fields replaced by the non-null parameter values. @@ -1408,7 +1407,6 @@ class _Log implements Log { const _Log({@JsonKey(name: 'LogLevel') this.logLevel = LogLevel.info, @JsonKey(name: 'Payload') this.payload = '', @JsonKey(fromJson: _logDateTime) required this.dateTime}); factory _Log.fromJson(Map json) => _$LogFromJson(json); -// @JsonKey(fromJson: _logId) required String id, @override@JsonKey(name: 'LogLevel') final LogLevel logLevel; @override@JsonKey(name: 'Payload') final String payload; @override@JsonKey(fromJson: _logDateTime) final String dateTime; @@ -2035,7 +2033,7 @@ as bool, /// @nodoc mixin _$DAVProps { - String get uri; String get user; String get password; String get fileName; + String get uri; String get user;@JsonKey(fromJson: _decodeDavPassword, toJson: _encodeDavPassword) String get password; String get fileName; /// Create a copy of DAVProps /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -2055,10 +2053,6 @@ bool operator ==(Object other) { @override int get hashCode => Object.hash(runtimeType,uri,user,password,fileName); -@override -String toString() { - return 'DAVProps(uri: $uri, user: $user, password: $password, fileName: $fileName)'; -} } @@ -2068,7 +2062,7 @@ abstract mixin class $DAVPropsCopyWith<$Res> { factory $DAVPropsCopyWith(DAVProps value, $Res Function(DAVProps) _then) = _$DAVPropsCopyWithImpl; @useResult $Res call({ - String uri, String user, String password, String fileName + String uri, String user,@JsonKey(fromJson: _decodeDavPassword, toJson: _encodeDavPassword) String password, String fileName }); @@ -2176,7 +2170,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String uri, String user, String password, String fileName)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String uri, String user, @JsonKey(fromJson: _decodeDavPassword, toJson: _encodeDavPassword) String password, String fileName)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _DAVProps() when $default != null: return $default(_that.uri,_that.user,_that.password,_that.fileName);case _: @@ -2197,7 +2191,7 @@ return $default(_that.uri,_that.user,_that.password,_that.fileName);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String uri, String user, String password, String fileName) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String uri, String user, @JsonKey(fromJson: _decodeDavPassword, toJson: _encodeDavPassword) String password, String fileName) $default,) {final _that = this; switch (_that) { case _DAVProps(): return $default(_that.uri,_that.user,_that.password,_that.fileName);case _: @@ -2217,7 +2211,7 @@ return $default(_that.uri,_that.user,_that.password,_that.fileName);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String uri, String user, String password, String fileName)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String uri, String user, @JsonKey(fromJson: _decodeDavPassword, toJson: _encodeDavPassword) String password, String fileName)? $default,) {final _that = this; switch (_that) { case _DAVProps() when $default != null: return $default(_that.uri,_that.user,_that.password,_that.fileName);case _: @@ -2231,13 +2225,13 @@ return $default(_that.uri,_that.user,_that.password,_that.fileName);case _: /// @nodoc @JsonSerializable() -class _DAVProps implements DAVProps { - const _DAVProps({required this.uri, required this.user, required this.password, this.fileName = defaultDavFileName}); +class _DAVProps extends DAVProps { + const _DAVProps({required this.uri, required this.user, @JsonKey(fromJson: _decodeDavPassword, toJson: _encodeDavPassword) this.password = '', this.fileName = defaultDavFileName}): super._(); factory _DAVProps.fromJson(Map json) => _$DAVPropsFromJson(json); @override final String uri; @override final String user; -@override final String password; +@override@JsonKey(fromJson: _decodeDavPassword, toJson: _encodeDavPassword) final String password; @override@JsonKey() final String fileName; /// Create a copy of DAVProps @@ -2260,10 +2254,6 @@ bool operator ==(Object other) { @override int get hashCode => Object.hash(runtimeType,uri,user,password,fileName); -@override -String toString() { - return 'DAVProps(uri: $uri, user: $user, password: $password, fileName: $fileName)'; -} } @@ -2273,7 +2263,7 @@ abstract mixin class _$DAVPropsCopyWith<$Res> implements $DAVPropsCopyWith<$Res> factory _$DAVPropsCopyWith(_DAVProps value, $Res Function(_DAVProps) _then) = __$DAVPropsCopyWithImpl; @override @useResult $Res call({ - String uri, String user, String password, String fileName + String uri, String user,@JsonKey(fromJson: _decodeDavPassword, toJson: _encodeDavPassword) String password, String fileName }); @@ -2306,7 +2296,7 @@ as String, /// @nodoc mixin _$FileInfo { - int get size; DateTime get lastModified; + int get size; DateTime? get lastModified; /// Create a copy of FileInfo /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -2337,7 +2327,7 @@ abstract mixin class $FileInfoCopyWith<$Res> { factory $FileInfoCopyWith(FileInfo value, $Res Function(FileInfo) _then) = _$FileInfoCopyWithImpl; @useResult $Res call({ - int size, DateTime lastModified + int size, DateTime? lastModified }); @@ -2354,11 +2344,11 @@ class _$FileInfoCopyWithImpl<$Res> /// Create a copy of FileInfo /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? size = null,Object? lastModified = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? size = null,Object? lastModified = freezed,}) { return _then(_self.copyWith( size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable -as int,lastModified: null == lastModified ? _self.lastModified : lastModified // ignore: cast_nullable_to_non_nullable -as DateTime, +as int,lastModified: freezed == lastModified ? _self.lastModified : lastModified // ignore: cast_nullable_to_non_nullable +as DateTime?, )); } @@ -2443,7 +2433,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( int size, DateTime lastModified)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( int size, DateTime? lastModified)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _FileInfo() when $default != null: return $default(_that.size,_that.lastModified);case _: @@ -2464,7 +2454,7 @@ return $default(_that.size,_that.lastModified);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( int size, DateTime lastModified) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( int size, DateTime? lastModified) $default,) {final _that = this; switch (_that) { case _FileInfo(): return $default(_that.size,_that.lastModified);case _: @@ -2484,7 +2474,7 @@ return $default(_that.size,_that.lastModified);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( int size, DateTime lastModified)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int size, DateTime? lastModified)? $default,) {final _that = this; switch (_that) { case _FileInfo() when $default != null: return $default(_that.size,_that.lastModified);case _: @@ -2499,11 +2489,11 @@ return $default(_that.size,_that.lastModified);case _: class _FileInfo implements FileInfo { - const _FileInfo({required this.size, required this.lastModified}); + const _FileInfo({required this.size, this.lastModified}); @override final int size; -@override final DateTime lastModified; +@override final DateTime? lastModified; /// Create a copy of FileInfo /// with the given fields replaced by the non-null parameter values. @@ -2535,7 +2525,7 @@ abstract mixin class _$FileInfoCopyWith<$Res> implements $FileInfoCopyWith<$Res> factory _$FileInfoCopyWith(_FileInfo value, $Res Function(_FileInfo) _then) = __$FileInfoCopyWithImpl; @override @useResult $Res call({ - int size, DateTime lastModified + int size, DateTime? lastModified }); @@ -2552,11 +2542,11 @@ class __$FileInfoCopyWithImpl<$Res> /// Create a copy of FileInfo /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? size = null,Object? lastModified = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? size = null,Object? lastModified = freezed,}) { return _then(_FileInfo( size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable -as int,lastModified: null == lastModified ? _self.lastModified : lastModified // ignore: cast_nullable_to_non_nullable -as DateTime, +as int,lastModified: freezed == lastModified ? _self.lastModified : lastModified // ignore: cast_nullable_to_non_nullable +as DateTime?, )); } diff --git a/lib/models/generated/common.g.dart b/lib/models/generated/common.g.dart index efd7254946..6c5d4b5f90 100644 --- a/lib/models/generated/common.g.dart +++ b/lib/models/generated/common.g.dart @@ -128,14 +128,16 @@ const _$LogLevelEnumMap = { _DAVProps _$DAVPropsFromJson(Map json) => _DAVProps( uri: json['uri'] as String, user: json['user'] as String, - password: json['password'] as String, + password: json['password'] == null + ? '' + : _decodeDavPassword(json['password'] as String?), fileName: json['fileName'] as String? ?? defaultDavFileName, ); Map _$DAVPropsToJson(_DAVProps instance) => { 'uri': instance.uri, 'user': instance.user, - 'password': instance.password, + 'password': _encodeDavPassword(instance.password), 'fileName': instance.fileName, }; diff --git a/lib/models/generated/core.freezed.dart b/lib/models/generated/core.freezed.dart index bc15672f9c..11647c217d 100644 --- a/lib/models/generated/core.freezed.dart +++ b/lib/models/generated/core.freezed.dart @@ -3360,275 +3360,6 @@ $SubscriptionInfoCopyWith<$Res>? get subscriptionInfo { } -/// @nodoc -mixin _$Action { - - ActionMethod get method; dynamic get data; String get id; -/// Create a copy of Action -/// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -$ActionCopyWith get copyWith => _$ActionCopyWithImpl(this as Action, _$identity); - - /// Serializes this Action to a JSON map. - Map toJson(); - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is Action&&(identical(other.method, method) || other.method == method)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.id, id) || other.id == id)); -} - -@JsonKey(includeFromJson: false, includeToJson: false) -@override -int get hashCode => Object.hash(runtimeType,method,const DeepCollectionEquality().hash(data),id); - -@override -String toString() { - return 'Action(method: $method, data: $data, id: $id)'; -} - - -} - -/// @nodoc -abstract mixin class $ActionCopyWith<$Res> { - factory $ActionCopyWith(Action value, $Res Function(Action) _then) = _$ActionCopyWithImpl; -@useResult -$Res call({ - ActionMethod method, dynamic data, String id -}); - - - - -} -/// @nodoc -class _$ActionCopyWithImpl<$Res> - implements $ActionCopyWith<$Res> { - _$ActionCopyWithImpl(this._self, this._then); - - final Action _self; - final $Res Function(Action) _then; - -/// Create a copy of Action -/// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? method = null,Object? data = freezed,Object? id = null,}) { - return _then(_self.copyWith( -method: null == method ? _self.method : method // ignore: cast_nullable_to_non_nullable -as ActionMethod,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable -as dynamic,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable -as String, - )); -} - -} - - -/// Adds pattern-matching-related methods to [Action]. -extension ActionPatterns on Action { -/// A variant of `map` that fallback to returning `orElse`. -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case final Subclass value: -/// return ...; -/// case _: -/// return orElse(); -/// } -/// ``` - -@optionalTypeArgs TResult maybeMap(TResult Function( _Action value)? $default,{required TResult orElse(),}){ -final _that = this; -switch (_that) { -case _Action() when $default != null: -return $default(_that);case _: - return orElse(); - -} -} -/// A `switch`-like method, using callbacks. -/// -/// Callbacks receives the raw object, upcasted. -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case final Subclass value: -/// return ...; -/// case final Subclass2 value: -/// return ...; -/// } -/// ``` - -@optionalTypeArgs TResult map(TResult Function( _Action value) $default,){ -final _that = this; -switch (_that) { -case _Action(): -return $default(_that);case _: - throw StateError('Unexpected subclass'); - -} -} -/// A variant of `map` that fallback to returning `null`. -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case final Subclass value: -/// return ...; -/// case _: -/// return null; -/// } -/// ``` - -@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Action value)? $default,){ -final _that = this; -switch (_that) { -case _Action() when $default != null: -return $default(_that);case _: - return null; - -} -} -/// A variant of `when` that fallback to an `orElse` callback. -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case _: -/// return orElse(); -/// } -/// ``` - -@optionalTypeArgs TResult maybeWhen(TResult Function( ActionMethod method, dynamic data, String id)? $default,{required TResult orElse(),}) {final _that = this; -switch (_that) { -case _Action() when $default != null: -return $default(_that.method,_that.data,_that.id);case _: - return orElse(); - -} -} -/// A `switch`-like method, using callbacks. -/// -/// As opposed to `map`, this offers destructuring. -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case Subclass2(:final field2): -/// return ...; -/// } -/// ``` - -@optionalTypeArgs TResult when(TResult Function( ActionMethod method, dynamic data, String id) $default,) {final _that = this; -switch (_that) { -case _Action(): -return $default(_that.method,_that.data,_that.id);case _: - throw StateError('Unexpected subclass'); - -} -} -/// A variant of `when` that fallback to returning `null` -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case _: -/// return null; -/// } -/// ``` - -@optionalTypeArgs TResult? whenOrNull(TResult? Function( ActionMethod method, dynamic data, String id)? $default,) {final _that = this; -switch (_that) { -case _Action() when $default != null: -return $default(_that.method,_that.data,_that.id);case _: - return null; - -} -} - -} - -/// @nodoc -@JsonSerializable() - -class _Action implements Action { - const _Action({required this.method, required this.data, required this.id}); - factory _Action.fromJson(Map json) => _$ActionFromJson(json); - -@override final ActionMethod method; -@override final dynamic data; -@override final String id; - -/// Create a copy of Action -/// with the given fields replaced by the non-null parameter values. -@override @JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -_$ActionCopyWith<_Action> get copyWith => __$ActionCopyWithImpl<_Action>(this, _$identity); - -@override -Map toJson() { - return _$ActionToJson(this, ); -} - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _Action&&(identical(other.method, method) || other.method == method)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.id, id) || other.id == id)); -} - -@JsonKey(includeFromJson: false, includeToJson: false) -@override -int get hashCode => Object.hash(runtimeType,method,const DeepCollectionEquality().hash(data),id); - -@override -String toString() { - return 'Action(method: $method, data: $data, id: $id)'; -} - - -} - -/// @nodoc -abstract mixin class _$ActionCopyWith<$Res> implements $ActionCopyWith<$Res> { - factory _$ActionCopyWith(_Action value, $Res Function(_Action) _then) = __$ActionCopyWithImpl; -@override @useResult -$Res call({ - ActionMethod method, dynamic data, String id -}); - - - - -} -/// @nodoc -class __$ActionCopyWithImpl<$Res> - implements _$ActionCopyWith<$Res> { - __$ActionCopyWithImpl(this._self, this._then); - - final _Action _self; - final $Res Function(_Action) _then; - -/// Create a copy of Action -/// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? method = null,Object? data = freezed,Object? id = null,}) { - return _then(_Action( -method: null == method ? _self.method : method // ignore: cast_nullable_to_non_nullable -as ActionMethod,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable -as dynamic,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable -as String, - )); -} - - -} - - /// @nodoc mixin _$ProxiesData { @@ -3904,278 +3635,6 @@ as List, } -} - - -/// @nodoc -mixin _$ActionResult { - - ActionMethod get method; dynamic get data; String? get id; ResultType get code; -/// Create a copy of ActionResult -/// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -$ActionResultCopyWith get copyWith => _$ActionResultCopyWithImpl(this as ActionResult, _$identity); - - /// Serializes this ActionResult to a JSON map. - Map toJson(); - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is ActionResult&&(identical(other.method, method) || other.method == method)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.id, id) || other.id == id)&&(identical(other.code, code) || other.code == code)); -} - -@JsonKey(includeFromJson: false, includeToJson: false) -@override -int get hashCode => Object.hash(runtimeType,method,const DeepCollectionEquality().hash(data),id,code); - -@override -String toString() { - return 'ActionResult(method: $method, data: $data, id: $id, code: $code)'; -} - - -} - -/// @nodoc -abstract mixin class $ActionResultCopyWith<$Res> { - factory $ActionResultCopyWith(ActionResult value, $Res Function(ActionResult) _then) = _$ActionResultCopyWithImpl; -@useResult -$Res call({ - ActionMethod method, dynamic data, String? id, ResultType code -}); - - - - -} -/// @nodoc -class _$ActionResultCopyWithImpl<$Res> - implements $ActionResultCopyWith<$Res> { - _$ActionResultCopyWithImpl(this._self, this._then); - - final ActionResult _self; - final $Res Function(ActionResult) _then; - -/// Create a copy of ActionResult -/// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? method = null,Object? data = freezed,Object? id = freezed,Object? code = null,}) { - return _then(_self.copyWith( -method: null == method ? _self.method : method // ignore: cast_nullable_to_non_nullable -as ActionMethod,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable -as dynamic,id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable -as String?,code: null == code ? _self.code : code // ignore: cast_nullable_to_non_nullable -as ResultType, - )); -} - -} - - -/// Adds pattern-matching-related methods to [ActionResult]. -extension ActionResultPatterns on ActionResult { -/// A variant of `map` that fallback to returning `orElse`. -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case final Subclass value: -/// return ...; -/// case _: -/// return orElse(); -/// } -/// ``` - -@optionalTypeArgs TResult maybeMap(TResult Function( _ActionResult value)? $default,{required TResult orElse(),}){ -final _that = this; -switch (_that) { -case _ActionResult() when $default != null: -return $default(_that);case _: - return orElse(); - -} -} -/// A `switch`-like method, using callbacks. -/// -/// Callbacks receives the raw object, upcasted. -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case final Subclass value: -/// return ...; -/// case final Subclass2 value: -/// return ...; -/// } -/// ``` - -@optionalTypeArgs TResult map(TResult Function( _ActionResult value) $default,){ -final _that = this; -switch (_that) { -case _ActionResult(): -return $default(_that);case _: - throw StateError('Unexpected subclass'); - -} -} -/// A variant of `map` that fallback to returning `null`. -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case final Subclass value: -/// return ...; -/// case _: -/// return null; -/// } -/// ``` - -@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ActionResult value)? $default,){ -final _that = this; -switch (_that) { -case _ActionResult() when $default != null: -return $default(_that);case _: - return null; - -} -} -/// A variant of `when` that fallback to an `orElse` callback. -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case _: -/// return orElse(); -/// } -/// ``` - -@optionalTypeArgs TResult maybeWhen(TResult Function( ActionMethod method, dynamic data, String? id, ResultType code)? $default,{required TResult orElse(),}) {final _that = this; -switch (_that) { -case _ActionResult() when $default != null: -return $default(_that.method,_that.data,_that.id,_that.code);case _: - return orElse(); - -} -} -/// A `switch`-like method, using callbacks. -/// -/// As opposed to `map`, this offers destructuring. -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case Subclass2(:final field2): -/// return ...; -/// } -/// ``` - -@optionalTypeArgs TResult when(TResult Function( ActionMethod method, dynamic data, String? id, ResultType code) $default,) {final _that = this; -switch (_that) { -case _ActionResult(): -return $default(_that.method,_that.data,_that.id,_that.code);case _: - throw StateError('Unexpected subclass'); - -} -} -/// A variant of `when` that fallback to returning `null` -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case _: -/// return null; -/// } -/// ``` - -@optionalTypeArgs TResult? whenOrNull(TResult? Function( ActionMethod method, dynamic data, String? id, ResultType code)? $default,) {final _that = this; -switch (_that) { -case _ActionResult() when $default != null: -return $default(_that.method,_that.data,_that.id,_that.code);case _: - return null; - -} -} - -} - -/// @nodoc -@JsonSerializable() - -class _ActionResult implements ActionResult { - const _ActionResult({required this.method, required this.data, this.id, this.code = ResultType.success}); - factory _ActionResult.fromJson(Map json) => _$ActionResultFromJson(json); - -@override final ActionMethod method; -@override final dynamic data; -@override final String? id; -@override@JsonKey() final ResultType code; - -/// Create a copy of ActionResult -/// with the given fields replaced by the non-null parameter values. -@override @JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -_$ActionResultCopyWith<_ActionResult> get copyWith => __$ActionResultCopyWithImpl<_ActionResult>(this, _$identity); - -@override -Map toJson() { - return _$ActionResultToJson(this, ); -} - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _ActionResult&&(identical(other.method, method) || other.method == method)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.id, id) || other.id == id)&&(identical(other.code, code) || other.code == code)); -} - -@JsonKey(includeFromJson: false, includeToJson: false) -@override -int get hashCode => Object.hash(runtimeType,method,const DeepCollectionEquality().hash(data),id,code); - -@override -String toString() { - return 'ActionResult(method: $method, data: $data, id: $id, code: $code)'; -} - - -} - -/// @nodoc -abstract mixin class _$ActionResultCopyWith<$Res> implements $ActionResultCopyWith<$Res> { - factory _$ActionResultCopyWith(_ActionResult value, $Res Function(_ActionResult) _then) = __$ActionResultCopyWithImpl; -@override @useResult -$Res call({ - ActionMethod method, dynamic data, String? id, ResultType code -}); - - - - -} -/// @nodoc -class __$ActionResultCopyWithImpl<$Res> - implements _$ActionResultCopyWith<$Res> { - __$ActionResultCopyWithImpl(this._self, this._then); - - final _ActionResult _self; - final $Res Function(_ActionResult) _then; - -/// Create a copy of ActionResult -/// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? method = null,Object? data = freezed,Object? id = freezed,Object? code = null,}) { - return _then(_ActionResult( -method: null == method ? _self.method : method // ignore: cast_nullable_to_non_nullable -as ActionMethod,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable -as dynamic,id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable -as String?,code: null == code ? _self.code : code // ignore: cast_nullable_to_non_nullable -as ResultType, - )); -} - - } // dart format on diff --git a/lib/models/generated/core.g.dart b/lib/models/generated/core.g.dart index 7e785dfad3..8db5436cb2 100644 --- a/lib/models/generated/core.g.dart +++ b/lib/models/generated/core.g.dart @@ -250,60 +250,6 @@ Map _$ExternalProviderToJson(_ExternalProvider instance) => 'update-at': instance.updateAt.toIso8601String(), }; -_Action _$ActionFromJson(Map json) => _Action( - method: $enumDecode(_$ActionMethodEnumMap, json['method']), - data: json['data'], - id: json['id'] as String, -); - -Map _$ActionToJson(_Action instance) => { - 'method': _$ActionMethodEnumMap[instance.method]!, - 'data': instance.data, - 'id': instance.id, -}; - -const _$ActionMethodEnumMap = { - ActionMethod.message: 'message', - ActionMethod.initClash: 'initClash', - ActionMethod.getIsInit: 'getIsInit', - ActionMethod.forceGc: 'forceGc', - ActionMethod.shutdown: 'shutdown', - ActionMethod.validateConfig: 'validateConfig', - ActionMethod.updateConfig: 'updateConfig', - ActionMethod.getConfig: 'getConfig', - ActionMethod.getProxies: 'getProxies', - ActionMethod.changeProxy: 'changeProxy', - ActionMethod.getTraffic: 'getTraffic', - ActionMethod.getTotalTraffic: 'getTotalTraffic', - ActionMethod.resetTraffic: 'resetTraffic', - ActionMethod.asyncTestDelay: 'asyncTestDelay', - ActionMethod.getConnections: 'getConnections', - ActionMethod.closeConnections: 'closeConnections', - ActionMethod.resetConnections: 'resetConnections', - ActionMethod.closeConnection: 'closeConnection', - ActionMethod.getExternalProviders: 'getExternalProviders', - ActionMethod.getExternalProvider: 'getExternalProvider', - ActionMethod.updateGeoData: 'updateGeoData', - ActionMethod.updateExternalProvider: 'updateExternalProvider', - ActionMethod.sideLoadExternalProvider: 'sideLoadExternalProvider', - ActionMethod.startLog: 'startLog', - ActionMethod.stopLog: 'stopLog', - ActionMethod.startListener: 'startListener', - ActionMethod.stopListener: 'stopListener', - ActionMethod.getCountryCode: 'getCountryCode', - ActionMethod.getMemory: 'getMemory', - ActionMethod.crash: 'crash', - ActionMethod.setupConfig: 'setupConfig', - ActionMethod.deleteFile: 'deleteFile', - ActionMethod.setState: 'setState', - ActionMethod.startTun: 'startTun', - ActionMethod.stopTun: 'stopTun', - ActionMethod.getRunTime: 'getRunTime', - ActionMethod.updateDns: 'updateDns', - ActionMethod.getAndroidVpnOptions: 'getAndroidVpnOptions', - ActionMethod.getCurrentProfileName: 'getCurrentProfileName', -}; - _ProxiesData _$ProxiesDataFromJson(Map json) => _ProxiesData( proxies: json['proxies'] as Map, all: (json['all'] as List).map((e) => e as String).toList(), @@ -311,23 +257,3 @@ _ProxiesData _$ProxiesDataFromJson(Map json) => _ProxiesData( Map _$ProxiesDataToJson(_ProxiesData instance) => {'proxies': instance.proxies, 'all': instance.all}; - -_ActionResult _$ActionResultFromJson(Map json) => - _ActionResult( - method: $enumDecode(_$ActionMethodEnumMap, json['method']), - data: json['data'], - id: json['id'] as String?, - code: - $enumDecodeNullable(_$ResultTypeEnumMap, json['code']) ?? - ResultType.success, - ); - -Map _$ActionResultToJson(_ActionResult instance) => - { - 'method': _$ActionMethodEnumMap[instance.method]!, - 'data': instance.data, - 'id': instance.id, - 'code': _$ResultTypeEnumMap[instance.code]!, - }; - -const _$ResultTypeEnumMap = {ResultType.success: 0, ResultType.error: -1}; diff --git a/lib/plugins/app.dart b/lib/plugins/app.dart index 612b7cc40e..39452d9c70 100644 --- a/lib/plugins/app.dart +++ b/lib/plugins/app.dart @@ -102,6 +102,17 @@ class App { if (!Platform.isAndroid) return false; return methodChannel.invokeMethod('openAppSettings'); } + + Future didCrashOnPreviousExecution() async { + try { + return await methodChannel.invokeMethod( + 'didCrashOnPreviousExecution', + ) ?? + false; + } catch (_) { + return false; + } + } } final app = system.isAndroid ? App() : null; diff --git a/lib/plugins/service.dart b/lib/plugins/service.dart index bb5f3ca4c1..17a1c2991f 100644 --- a/lib/plugins/service.dart +++ b/lib/plugins/service.dart @@ -3,14 +3,15 @@ import 'dart:convert'; import 'dart:isolate'; import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/core/event.dart'; +import 'package:fl_clash/core/method.dart'; +import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/models/models.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; abstract mixin class ServiceListener { void onServiceEvent(CoreEvent event) {} - - void onServiceCrash(String message) {} } class Service { @@ -32,15 +33,21 @@ class Service { switch (call.method) { case 'event': final data = call.arguments as String? ?? ''; - final result = ActionResult.fromJson(json.decode(data)); - for (final listener in _listeners) { - listener.onServiceEvent(CoreEvent.fromJson(result.data)); - } - break; - case 'crash': - final message = call.arguments as String? ?? ''; - for (final listener in _listeners) { - listener.onServiceCrash(message); + final methodCall = CoreMethodCall.fromJson( + Map.from(json.decode(data) as Map), + ); + for (final event in coreEventsFromData(methodCall.arguments)) { + for (final listener in _listeners) { + try { + listener.onServiceEvent(event); + } catch (error) { + commonPrint.log( + 'Unable to dispatch Android Core event ' + '${event.type.name}: $error', + logLevel: LogLevel.error, + ); + } + } } break; default: @@ -49,16 +56,16 @@ class Service { }); } - Future invokeAction(Action action) async { + Future invokeMethod(CoreMethodCall call) async { final data = await methodChannel.invokeMethod( - 'invokeAction', - json.encode(action), + 'invokeMethod', + json.encode(call), ); if (data == null) { return null; } final dataJson = await data.commonToJSON(); - return ActionResult.fromJson(dataJson); + return CoreMethodResponse.fromJson(dataJson); } Future start() async { diff --git a/lib/providers/action.dart b/lib/providers/action.dart index c444723c7c..9ea44cd0ed 100644 --- a/lib/providers/action.dart +++ b/lib/providers/action.dart @@ -9,988 +9,22 @@ import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/plugins/app.dart'; import 'package:fl_clash/plugins/service.dart'; import 'package:fl_clash/providers/providers.dart'; +import 'package:fl_clash/providers/actions/system_exit.dart'; import 'package:fl_clash/state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' show basename; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:url_launcher/url_launcher.dart'; +part 'actions/common.dart'; +part 'actions/setup.dart'; +part 'actions/backup.dart'; +part 'actions/core.dart'; +part 'actions/system.dart'; +part 'actions/store.dart'; +part 'actions/theme.dart'; +part 'actions/proxies.dart'; +part 'actions/profiles.dart'; +part 'actions/geo_resource.dart'; part 'generated/action.g.dart'; - -@Riverpod(keepAlive: true) -class CommonAction extends _$CommonAction { - @override - void build() {} - - void updateStart() { - ref - .read(setupActionProvider.notifier) - .updateStatus(!ref.read(isStartProvider)); - } - - void updateSpeedStatistics() { - ref - .read(appSettingProvider.notifier) - .update((state) => state.copyWith(showTrayTitle: !state.showTrayTitle)); - } - - void updateMode() { - ref.read(patchClashConfigProvider.notifier).update((state) { - final index = Mode.values.indexWhere((item) => item == state.mode); - if (index == -1) return state; - final nextIndex = index + 1 > Mode.values.length - 1 ? 0 : index + 1; - return state.copyWith(mode: Mode.values[nextIndex]); - }); - } - - void updateRunTime() { - final startTime = ref.read(setupActionProvider.notifier).startTime; - if (startTime != null) { - final startTimeStamp = startTime.millisecondsSinceEpoch; - final nowTimeStamp = DateTime.now().millisecondsSinceEpoch; - ref.read(runTimeProvider.notifier).value = nowTimeStamp - startTimeStamp; - } else { - ref.read(runTimeProvider.notifier).value = null; - } - } - - Future updateTraffic() async { - final onlyStatisticsProxy = ref.read( - appSettingProvider.select((state) => state.onlyStatisticsProxy), - ); - final traffic = await coreController.getTraffic(onlyStatisticsProxy); - ref.read(trafficsProvider.notifier).addTraffic(traffic); - ref.read(totalTrafficProvider.notifier).value = await coreController - .getTotalTraffic(onlyStatisticsProxy); - } - - Future autoCheckUpdate() async { - if (!ref.read(appSettingProvider).autoCheckUpdate) return; - final res = await request.checkForUpdate(); - checkUpdateResultHandle(data: res); - } - - Future checkUpdateResultHandle({ - Map? data, - bool isUser = false, - }) async { - if (data != null) { - final tagName = data['tag_name']; - final body = data['body']; - final submits = utils.parseReleaseBody(body); - final context = globalState.navigatorKey.currentContext!; - final textTheme = context.textTheme; - final res = await globalState.showMessage( - title: currentAppLocalizations.discoverNewVersion, - message: TextSpan( - text: '$tagName \n', - style: textTheme.headlineSmall, - children: [ - TextSpan(text: '\n', style: textTheme.bodyMedium), - for (final submit in submits) - TextSpan(text: '- $submit \n', style: textTheme.bodyMedium), - ], - ), - confirmText: currentAppLocalizations.goDownload, - cancelText: isUser ? null : currentAppLocalizations.noLongerRemind, - ); - if (res == true) { - launchUrl(Uri.parse('https://github.com/$repository/releases/latest')); - } else if (!isUser && res == false) { - ref - .read(appSettingProvider.notifier) - .update((state) => state.copyWith(autoCheckUpdate: false)); - } - } else if (isUser) { - globalState.showMessage( - title: currentAppLocalizations.checkUpdate, - message: TextSpan(text: currentAppLocalizations.checkUpdateError), - ); - } - } -} - -@Riverpod(keepAlive: true) -class SetupAction extends _$SetupAction { - Timer? _updateTimer; - DateTime? startTime; - - bool get isStart => startTime != null && startTime!.isBeforeNow; - - @override - void build() {} - - SetupParams get _setupParams { - final selectedMap = ref.read(selectedMapProvider); - final testUrl = ref.read( - appSettingProvider.select((state) => state.testUrl), - ); - return SetupParams(selectedMap: selectedMap, testUrl: testUrl); - } - - void fullSetup() { - if (!ref.read(initProvider)) return; - ref.read(delayDataSourceProvider.notifier).value = {}; - applyProfile(force: true); - ref.read(logsProvider.notifier).value = FixedList(500); - ref.read(requestsProvider.notifier).value = FixedList(500); - } - - Future _handleStart() async { - startTime ??= DateTime.now(); - //The local status must be updated when performing the run task - ref.read(commonActionProvider.notifier).updateRunTime(); - ref.read(commonActionProvider.notifier).updateTraffic(); - if (!ref.read(suspendProvider)) { - await coreController.startListener(); - } - _updateTimer = Timer.periodic(const Duration(seconds: 1), (_) { - ref.read(commonActionProvider.notifier).updateRunTime(); - ref.read(commonActionProvider.notifier).updateTraffic(); - }); - } - - Future _updateStartTime() async { - startTime = await service?.getRunTime(); - } - - Future handleStop() async { - startTime = null; - _updateTimer?.cancel(); - _updateTimer = null; - await coreController.stopListener(); - } - - Future initStatus() async { - if (!globalState.needInitStatus) { - commonPrint.log('init status cancel'); - return; - } - commonPrint.log('init status'); - if (system.isAndroid) { - await _updateStartTime(); - } - final status = isStart == true - ? true - : ref.read(appSettingProvider).autoRun; - if (status == true) { - await updateStatus(true, isInit: true); - } else { - await applyProfile(force: true); - } - } - - Future updateStatus(bool isStart, {bool isInit = false}) async { - if (isStart) { - if (!isInit) { - final res = await ref - .read(coreActionProvider.notifier) - .tryStartCore(true); - if (res) return; - if (!ref.read(initProvider)) return; - await _handleStart(); - applyProfileDebounce(force: true, silence: true); - } else { - globalState.needInitStatus = false; - ref.read(runTimeProvider.notifier).value = 0; - try { - await applyProfile( - force: true, - preloadInvoke: () async { - await _handleStart(); - }, - ); - } catch (_) { - ref.read(runTimeProvider.notifier).value = null; - } - } - } else { - await handleStop(); - coreController.resetTraffic(); - ref.read(trafficsProvider.notifier).clear(); - ref.read(totalTrafficProvider.notifier).value = const Traffic(); - ref.read(runTimeProvider.notifier).value = null; - ref.read(checkIpNumProvider.notifier).add(); - } - } - - Future updateConfigDebounce() async { - debouncer.call(FunctionTag.updateConfig, () async { - await globalState.safeRun(() async { - final updateParams = ref.read(updateParamsProvider); - final res = await _requestAdmin(updateParams.tun.enable); - if (res.isError) return; - final realTunEnable = ref.read(realTunEnableProvider); - final message = await coreController.updateConfig( - updateParams.copyWith.tun(enable: realTunEnable), - ); - ref.read(checkIpNumProvider.notifier).add(); - if (message.isNotEmpty) throw message; - }); - }); - } - - void tryCheckIp() { - final isTimeout = ref.read( - networkDetectionProvider.select( - (state) => state.ipInfo == null && state.isLoading == false, - ), - ); - if (!isTimeout) return; - ref.read(checkIpNumProvider.notifier).add(); - } - - void applyProfileDebounce({bool silence = false, bool force = false}) { - debouncer.call(FunctionTag.applyProfile, (silence, force) { - applyProfile(silence: silence, force: force); - }, args: [silence, force]); - } - - void changeMode(Mode mode) { - ref - .read(patchClashConfigProvider.notifier) - .update((state) => state.copyWith(mode: mode)); - if (mode == Mode.global) { - ref - .read(proxiesActionProvider.notifier) - .updateCurrentGroupName(GroupName.GLOBAL.name); - } - } - - void autoApplyProfile() { - WidgetsBinding.instance.addPostFrameCallback((_) { - applyProfile(); - }); - } - - Future applyProfile({ - bool silence = false, - bool force = false, - VoidCallback? preloadInvoke, - }) async { - await _setupConfig( - force: force, - silence: silence, - preloadInvoke: preloadInvoke, - onUpdated: () async { - await ref.read(proxiesActionProvider.notifier).updateGroups(); - await ref.read(providersProvider.notifier).syncProviders(); - }, - ); - } - - Future> getProfile({ - required SetupState setupState, - required PatchClashConfig patchConfig, - }) async { - final profileId = setupState.profileId; - if (profileId == null) return const VM2('', ''); - final defaultUA = globalState.packageInfo.ua; - final networkVM2 = ref.read( - networkSettingProvider.select( - (state) => VM2(state.appendSystemDns, state.routeMode), - ), - ); - final overrideDns = ref.read(overrideDnsProvider); - final appendSystemDns = networkVM2.a; - final routeMode = networkVM2.b; - final configMap = await coreController.getConfig(profileId); - String? scriptContent; - final List addedRules = []; - final List proxyGroups = []; - final List rules = []; - if (setupState.overwriteType == OverwriteType.script) { - scriptContent = await setupState.script?.content; - } else if (setupState.overwriteType == OverwriteType.standard) { - addedRules.addAll(setupState.addedRules); - } else { - proxyGroups.addAll(setupState.proxyGroups); - rules.addAll(setupState.rules); - } - final realPatchConfig = patchConfig.copyWith( - tun: patchConfig.tun.getRealTun(routeMode), - ); - Map rawConfig = configMap; - if (scriptContent?.isNotEmpty == true) { - rawConfig = await handleEvaluate(scriptContent!, rawConfig); - } - final directory = await appPath.profilesPath; - final res = makeRealProfileTask( - MakeRealProfileState( - rules: rules, - proxyGroups: proxyGroups, - profilesPath: directory, - profileId: profileId, - rawConfig: rawConfig, - realPatchConfig: realPatchConfig, - overrideDns: overrideDns, - appendSystemDns: appendSystemDns, - addedRules: addedRules, - defaultUA: defaultUA, - ), - ); - return res; - } - - Future getProfileWithId(int profileId) async { - try { - final setupState = await ref.read(setupStateProvider(profileId).future); - final patchClashConfig = ref.read(patchClashConfigProvider); - final res = await getProfile( - setupState: setupState, - patchConfig: patchClashConfig, - ); - return res.a; - } catch (e) { - globalState.showNotifier(e.toString()); - } - return ''; - } - - Future> _requestAdmin(bool enableTun) async { - final realTunEnable = ref.read(realTunEnableProvider); - if (enableTun != realTunEnable && realTunEnable == false) { - final code = await system.authorizeCore(); - switch (code) { - case AuthorizeCode.success: - await ref.read(coreActionProvider.notifier).restartCore(); - return Result.error(''); - case AuthorizeCode.none: - break; - case AuthorizeCode.error: - enableTun = false; - break; - } - } - ref.read(realTunEnableProvider.notifier).value = enableTun; - return Result.success(enableTun); - } - - Future _setupConfig({ - bool force = false, - bool silence = false, - VoidCallback? preloadInvoke, - FutureOr Function()? onUpdated, - }) async { - var profile = ref.read(currentProfileProvider); - final nextProfile = await profile?.checkAndUpdateAndCopy(); - if (nextProfile != null) { - profile = nextProfile; - ref.read(profilesProvider.notifier).put(nextProfile); - } - commonPrint.log('setup ===> ${profile?.id}'); - final patchConfig = ref.read(patchClashConfigProvider); - final res = await _requestAdmin(patchConfig.tun.enable); - if (res.isError) return; - final realTunEnable = ref.read(realTunEnableProvider); - final realPatchConfig = patchConfig.copyWith.tun(enable: realTunEnable); - final setupState = await ref.read(setupStateProvider(profile?.id).future); - if (system.isAndroid) { - globalState.lastVpnState = ref.read(vpnStateProvider); - final sharedState = ref.read(sharedStateProvider); - preferences.saveShareState(sharedState); - } - final vm2 = await getProfile( - setupState: setupState, - patchConfig: realPatchConfig, - ); - final yamlString = vm2.a; - final yamlMd5 = vm2.b; - if (yamlMd5 == globalState.lastConfigMd5 && force == false) return; - await globalState.loadingRun( - () async { - final configFilePath = await appPath.configFilePath; - await File(configFilePath).safeWriteAsString(yamlString); - globalState.lastConfigMd5 = yamlMd5; - final message = await coreController.setupConfig( - setupState: setupState, - params: _setupParams, - preloadInvoke: preloadInvoke, - ); - if (message.isNotEmpty && !message.endsWith('is empty')) { - throw message; - } - ref.read(checkIpNumProvider.notifier).add(); - await onUpdated?.call(); - }, - silence: true, - tag: !silence ? LoadingTag.proxies : null, - ); - } -} - -@Riverpod(keepAlive: true) -class BackupAction extends _$BackupAction { - @override - void build() {} - - Future backup() async { - final res = await Future.wait([ - database.profilesDao.fileNames().get(), - database.scriptsDao.fileNames().get(), - ]); - final profileFileNames = res[0]; - final scriptFileNames = res[1]; - final configMap = ref.read(configProvider).toJson(); - configMap['version'] = await preferences.getVersion(); - return backupTask(configMap, [...profileFileNames, ...scriptFileNames]); - } - - Future restore(RestoreOption option) async { - final restoreDirPath = await appPath.restoreDirPath; - final restoreDir = Directory(restoreDirPath); - final restoreStrategy = ref.read( - appSettingProvider.select((state) => state.restoreStrategy), - ); - final isOverride = restoreStrategy == RestoreStrategy.override; - try { - final migrationData = await restoreTask(); - if (!await restoreDir.exists()) { - throw currentAppLocalizations.restoreException; - } - await database.restore( - migrationData.profiles, - migrationData.scripts, - migrationData.rules, - migrationData.links, - migrationData.proxyGroups, - isOverride: isOverride, - ); - final configMap = migrationData.configMap; - if (option == RestoreOption.onlyProfiles || configMap == null) return; - final config = Config.fromJson(configMap); - ref.read(patchClashConfigProvider.notifier).value = - config.patchClashConfig; - ref.read(appSettingProvider.notifier).value = config.appSettingProps; - ref.read(currentProfileIdProvider.notifier).value = - config.currentProfileId; - ref.read(davSettingProvider.notifier).value = config.davProps; - ref.read(themeSettingProvider.notifier).value = config.themeProps; - ref.read(windowSettingProvider.notifier).value = config.windowProps; - ref.read(vpnSettingProvider.notifier).value = config.vpnProps; - ref.read(proxiesStyleSettingProvider.notifier).value = - config.proxiesStyleProps; - ref.read(overrideDnsProvider.notifier).value = config.overrideDns; - ref.read(networkSettingProvider.notifier).value = config.networkProps; - ref.read(hotKeyActionsProvider.notifier).value = config.hotKeyActions; - return; - } finally { - await restoreDir.safeDelete(recursive: true); - } - } -} - -@Riverpod(keepAlive: true) -class CoreAction extends _$CoreAction { - @override - void build() {} - - Future initCore() async { - final isInit = await coreController.isInit; - - final version = ref.read(versionProvider); - if (!isInit) { - final res = await coreController.init(version); - commonPrint.log('init result: $res'); - } else { - await ref.read(proxiesActionProvider.notifier).updateGroups(); - } - } - - Future connectCore() async { - ref.read(coreStatusProvider.notifier).value = CoreStatus.connecting; - final result = await Future.wait([ - coreController.preload(), - Future.delayed(const Duration(milliseconds: 300)), - ]); - final String message = result[0]; - if (message.isNotEmpty) { - ref.read(coreStatusProvider.notifier).value = CoreStatus.disconnected; - globalState.showNotifier(message); - return; - } - ref.read(coreStatusProvider.notifier).value = CoreStatus.connected; - } - - Future> requestAdmin(bool enableTun) async { - final realTunEnable = ref.read(realTunEnableProvider); - if (enableTun != realTunEnable && realTunEnable == false) { - final code = await system.authorizeCore(); - switch (code) { - case AuthorizeCode.success: - await restartCore(); - return Result.error(''); - case AuthorizeCode.none: - break; - case AuthorizeCode.error: - enableTun = false; - break; - } - } - ref.read(realTunEnableProvider.notifier).value = enableTun; - return Result.success(enableTun); - } - - Future restartCore([bool start = false]) async { - final isDisconnected = - ref.read(coreStatusProvider) == CoreStatus.disconnected; - ref.read(coreStatusProvider.notifier).value = CoreStatus.disconnected; - await coreController.shutdown(!isDisconnected); - await connectCore(); - await initCore(); - if (start || ref.read(isStartProvider)) { - await ref - .read(setupActionProvider.notifier) - .updateStatus(true, isInit: true); - } else { - await ref.read(setupActionProvider.notifier).applyProfile(force: true); - } - } - - Future tryStartCore([bool start = false]) async { - if (coreController.isCompleted) return false; - await restartCore(start); - return true; - } - - void handleCoreDisconnected() { - ref.read(coreStatusProvider.notifier).value = CoreStatus.disconnected; - } -} - -@Riverpod(keepAlive: true) -class SystemAction extends _$SystemAction { - @override - void build() {} - - Future> getPackages() async { - if (ref.read(isMobileViewProvider)) { - await Future.delayed(commonDuration); - } - if (ref.read(packagesProvider).isEmpty) { - ref.read(packagesProvider.notifier).value = - await app?.getPackages() ?? []; - } - return ref.read(packagesProvider); - } - - Future handleExit([bool needSave = false]) async { - Future.delayed(const Duration(seconds: 3), () { - system.exit(); - }); - try { - await Future.wait([ - if (needSave) preferences.saveConfig(ref.read(configProvider)), - if (macOS != null) macOS!.updateDns(true), - if (proxy != null) proxy!.stopProxy(), - if (tray != null) tray!.destroy(), - ]); - await window?.close(); - await coreController.destroy(); - commonPrint.log('exit'); - } finally { - system.exit(); - } - } - - Future handleClose([bool exit = true]) async { - if (!system.isDesktop) { - if (ref.read(backBlockProvider)) return; - } - if (ref.read(appSettingProvider).minimizeOnExit || !exit) { - if (system.isDesktop) { - await preferences.saveConfig(ref.read(configProvider)); - } - await system.back(); - } else { - await handleExit(); - } - } - - Future updateVisible() async { - final visible = await window?.isVisible; - if (visible != null && !visible) { - window?.show(); - } else { - window?.hide(); - } - } - - void updateTun() { - ref - .read(patchClashConfigProvider.notifier) - .update((state) => state.copyWith.tun(enable: !state.tun.enable)); - } - - void updateSystemProxy() { - ref - .read(networkSettingProvider.notifier) - .update((state) => state.copyWith(systemProxy: !state.systemProxy)); - } - - void updateAutoLaunch() { - ref - .read(appSettingProvider.notifier) - .update((state) => state.copyWith(autoLaunch: !state.autoLaunch)); - } - - Future updateTray() async { - tray?.update( - trayState: ref.read(trayStateProvider), - traffic: ref.read( - trafficsProvider.select( - (state) => state.list.safeLast(const Traffic()), - ), - ), - ); - } - - Future updateLocalIp() async { - ref.read(localIpProvider.notifier).value = null; - await Future.delayed(commonDuration); - ref.read(localIpProvider.notifier).value = await utils.getLocalIpAddress(); - } -} - -@Riverpod(keepAlive: true) -class StoreAction extends _$StoreAction { - @override - void build() {} - - Future shakingStore() async { - final profileIds = ref.read( - profilesProvider.select((state) => state.map((item) => item.id)), - ); - final scriptIds = await ref.read( - scriptsProvider.future.select( - (state) async => (await state).map((item) => item.id), - ), - ); - final pathsToDelete = await shakingProfileTask(VM2(profileIds, scriptIds)); - if (pathsToDelete.isNotEmpty) { - final deleteFutures = pathsToDelete.map((path) async { - try { - final res = await coreController.deleteFile(path); - if (res.isNotEmpty) throw res; - } catch (e) { - rethrow; - } - }); - await Future.wait(deleteFutures); - } - } - - void savePreferencesDebounce() { - debouncer.call(FunctionTag.savePreferences, () async { - await preferences.saveConfig(ref.read(configProvider)); - }); - } - - Future handleClear() async { - await preferences.clearPreferences(); - commonPrint.log('clear preferences'); - await database.close(); - await File(await appPath.databasePath).safeDelete(recursive: true); - final homeDir = Directory(await appPath.profilesPath); - await for (final file in homeDir.list(recursive: true)) { - await coreController.deleteFile(file.path); - } - await preferences.clearPreferences(); - ref.read(systemActionProvider.notifier).handleExit(false); - } -} - -@Riverpod(keepAlive: true) -class ThemeAction extends _$ThemeAction { - @override - void build() {} - - void updateBrightness() { - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(systemBrightnessProvider.notifier).value = - WidgetsBinding.instance.platformDispatcher.platformBrightness; - }); - } - - void updateViewSize(Size size) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(viewSizeProvider.notifier).value = size; - }); - } -} - -@Riverpod(keepAlive: true) -class ProxiesAction extends _$ProxiesAction { - @override - void build() {} - - void updateGroupsDebounce([Duration? duration]) { - debouncer.call(FunctionTag.updateGroups, updateGroups, duration: duration); - } - - void changeProxyDebounce(String groupName, String proxyName) { - debouncer.call(FunctionTag.changeProxy, ( - String groupName, - String proxyName, - ) async { - await changeProxy(groupName: groupName, proxyName: proxyName); - updateGroupsDebounce(); - }, args: [groupName, proxyName]); - } - - Future updateGroups() async { - try { - commonPrint.log('updateGroups'); - ref.read(groupsProvider.notifier).value = await retry( - task: () async { - final sortType = ref.read( - proxiesStyleSettingProvider.select((state) => state.sortType), - ); - final delayMap = ref.read(delayDataSourceProvider); - final testUrl = ref.read( - appSettingProvider.select((state) => state.testUrl), - ); - final selectedMap = ref.read( - currentProfileProvider.select((state) => state?.selectedMap ?? {}), - ); - return coreController.getProxiesGroups( - selectedMap: selectedMap, - sortType: sortType, - delayMap: delayMap, - defaultTestUrl: testUrl, - ); - }, - retryIf: (res) => res.isEmpty, - ); - } catch (e) { - commonPrint.log('updateGroups error: $e'); - ref.read(groupsProvider.notifier).value = []; - } - } - - void updateCurrentGroupName(String groupName) { - final profile = ref.read(currentProfileProvider); - if (profile == null || profile.currentGroupName == groupName) return; - ref - .read(profilesProvider.notifier) - .put(profile.copyWith(currentGroupName: groupName)); - } - - void updateCurrentUnfoldSet(Set value) { - final currentProfile = ref.read(currentProfileProvider); - if (currentProfile == null) return; - ref - .read(profilesProvider.notifier) - .put(currentProfile.copyWith(unfoldSet: value)); - } - - void setDelay(Delay delay) { - ref.read(delayDataSourceProvider.notifier).setDelay(delay); - } - - Future changeProxy({ - required String groupName, - required String proxyName, - }) async { - await coreController.changeProxy( - ChangeProxyParams(groupName: groupName, proxyName: proxyName), - ); - if (ref.read(appSettingProvider).closeConnections) { - await coreController.closeConnections(); - } else { - await coreController.resetConnections(); - } - ref.read(checkIpNumProvider.notifier).add(); - } - - Future updateProvider( - ExternalProvider provider, { - bool showLoading = false, - }) async { - try { - if (showLoading) { - ref.read(isUpdatingProvider(provider.updatingKey).notifier).value = - true; - } - final message = await coreController.updateExternalProvider( - providerName: provider.name, - ); - if (message.isNotEmpty) return message; - ref - .read(providersProvider.notifier) - .setProvider(await coreController.getExternalProvider(provider.name)); - return ''; - } finally { - ref.read(isUpdatingProvider(provider.updatingKey).notifier).value = false; - } - } -} - -@Riverpod(keepAlive: true) -class ProfilesAction extends _$ProfilesAction { - @override - void build() {} - - void updateCurrentSelectedMap(String groupName, String proxyName) { - final currentProfile = ref.read(currentProfileProvider); - if (currentProfile != null && - currentProfile.selectedMap[groupName] != proxyName) { - final selectedMap = Map.from(currentProfile.selectedMap) - ..[groupName] = proxyName; - ref - .read(profilesProvider.notifier) - .put(currentProfile.copyWith(selectedMap: selectedMap)); - } - } - - Future deleteProfile(int id) async { - ref.read(profilesProvider.notifier).del(id); - clearEffect(id); - final currentProfileId = ref.read(currentProfileIdProvider); - if (currentProfileId == id) { - final profiles = ref.read(profilesProvider); - if (profiles.isNotEmpty) { - final updateId = profiles.first.id; - ref.read(currentProfileIdProvider.notifier).value = updateId; - } else { - ref.read(currentProfileIdProvider.notifier).value = null; - ref.read(setupActionProvider.notifier).updateStatus(false); - } - } - } - - Future autoUpdateProfiles() async { - for (final profile in ref.read(profilesProvider)) { - if (!profile.autoUpdate) continue; - final isNotNeedUpdate = profile.lastUpdateDate - ?.add(profile.autoUpdateDuration) - .isBeforeNow; - if (isNotNeedUpdate == false || profile.type == ProfileType.file) { - continue; - } - try { - await updateProfile(profile); - } catch (e) { - commonPrint.log(e.toString(), logLevel: LogLevel.warning); - } - } - } - - void putProfile(Profile profile) { - ref.read(profilesProvider.notifier).put(profile); - if (ref.read(currentProfileIdProvider) != null) return; - ref.read(currentProfileIdProvider.notifier).value = profile.id; - } - - Future updateProfiles() async { - for (final profile in ref.read(profilesProvider)) { - if (profile.type == ProfileType.file) continue; - await updateProfile(profile); - } - } - - Future updateProfile( - Profile profile, { - bool showLoading = false, - }) async { - try { - if (showLoading) { - ref.read(isUpdatingProvider(profile.updatingKey).notifier).value = true; - } - ref.read(profilesProvider.notifier).put(profile); - final newProfile = await profile.update(); - ref.read(profilesProvider.notifier).put(newProfile); - if (profile.id == ref.read(currentProfileIdProvider)) { - ref - .read(setupActionProvider.notifier) - .applyProfileDebounce(silence: true); - } - } finally { - ref.read(isUpdatingProvider(profile.updatingKey).notifier).value = false; - } - } - - Future addProfileFormFile() async { - final platformFile = await globalState.safeRun(picker.pickerFile); - if (platformFile == null) return; - final bytes = await platformFile.readBytes(); - globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst); - ref.read(currentPageLabelProvider.notifier).toProfiles(); - final profile = await globalState.loadingRun( - tag: LoadingTag.profiles, - () async { - return Profile.normal(label: platformFile.name).saveFile(bytes); - }, - title: currentAppLocalizations.addProfile, - ); - if (profile != null) { - putProfile(profile); - } - } - - Future addProfileFormURL(String url) async { - if (globalState.navigatorKey.currentState?.canPop() ?? false) { - globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst); - } - ref.read(currentPageLabelProvider.notifier).value = PageLabel.profiles; - final profile = await globalState.loadingRun( - tag: LoadingTag.profiles, - () async { - return Profile.normal(url: url).update(); - }, - title: currentAppLocalizations.addProfile, - ); - if (profile != null) { - putProfile(profile); - } - } - - void setProfileAndAutoApply(Profile profile) { - ref.read(profilesProvider.notifier).put(profile); - if (profile.id == ref.read(currentProfileIdProvider)) { - ref.read(setupActionProvider.notifier).applyProfileDebounce(); - } - } - - Future addProfileFormQrCode() async { - final url = await globalState.safeRun(picker.pickerConfigQRCode); - if (url == null) return; - addProfileFormURL(url); - } - - void reorder(List profiles) { - ref.read(profilesProvider.notifier).reorder(profiles); - } - - Future clearEffect(int profileId) async { - final profilePath = await appPath.getProfilePath(profileId.toString()); - final providersDirPath = await appPath.getProvidersDirPath( - profileId.toString(), - ); - final profileFile = File(profilePath); - final isExists = await profileFile.exists(); - if (isExists) { - await profileFile.safeDelete(recursive: true); - } - await coreController.deleteFile(providersDirPath); - } -} - -@Riverpod(keepAlive: true) -class GeoResourceAction extends _$GeoResourceAction { - @override - void build() {} - - Future updateGeoResource(GeoResource geoResource) async { - await coreController.updateGeoData(geoResource.name); - } - - void updateGeoResourceUrl(GeoResource geoResource, String newUrl) { - if (!newUrl.isUrl) { - throw 'Invalid url'; - } - ref.read(patchClashConfigProvider.notifier).update((state) { - return state.copyWith(geoXUrl: {...state.geoXUrl, geoResource: newUrl}); - }); - } -} diff --git a/lib/providers/actions/backup.dart b/lib/providers/actions/backup.dart new file mode 100644 index 0000000000..7bab8707ce --- /dev/null +++ b/lib/providers/actions/backup.dart @@ -0,0 +1,62 @@ +part of '../action.dart'; + +@Riverpod(keepAlive: true) +class BackupAction extends _$BackupAction { + @override + void build() {} + + Future backup() async { + final res = await Future.wait([ + database.profilesDao.fileNames().get(), + database.scriptsDao.fileNames().get(), + ]); + final profileFileNames = res[0]; + final scriptFileNames = res[1]; + final configMap = ref.read(configProvider).toJson(); + configMap['version'] = await preferences.getVersion(); + return backupTask(configMap, [...profileFileNames, ...scriptFileNames]); + } + + Future restore(RestoreOption option) async { + final restoreDirPath = await appPath.restoreDirPath; + final restoreDir = Directory(restoreDirPath); + final restoreStrategy = ref.read( + appSettingProvider.select((state) => state.restoreStrategy), + ); + final isOverride = restoreStrategy == RestoreStrategy.override; + try { + final migrationData = await restoreTask(); + if (!await restoreDir.exists()) { + throw currentAppLocalizations.restoreException; + } + await database.restore( + migrationData.profiles, + migrationData.scripts, + migrationData.rules, + migrationData.links, + migrationData.proxyGroups, + isOverride: isOverride, + ); + final configMap = migrationData.configMap; + if (option == RestoreOption.onlyProfiles || configMap == null) return; + final config = Config.fromJson(configMap); + ref.read(davSettingProvider.notifier).update((_) => config.davProps); + ref.read(patchClashConfigProvider.notifier).value = + config.patchClashConfig; + ref.read(appSettingProvider.notifier).value = config.appSettingProps; + ref.read(currentProfileIdProvider.notifier).value = + config.currentProfileId; + ref.read(themeSettingProvider.notifier).value = config.themeProps; + ref.read(windowSettingProvider.notifier).value = config.windowProps; + ref.read(vpnSettingProvider.notifier).value = config.vpnProps; + ref.read(proxiesStyleSettingProvider.notifier).value = + config.proxiesStyleProps; + ref.read(overrideDnsProvider.notifier).value = config.overrideDns; + ref.read(networkSettingProvider.notifier).value = config.networkProps; + ref.read(hotKeyActionsProvider.notifier).value = config.hotKeyActions; + return; + } finally { + await restoreDir.safeDelete(recursive: true); + } + } +} diff --git a/lib/providers/actions/common.dart b/lib/providers/actions/common.dart new file mode 100644 index 0000000000..ce3ed77b73 --- /dev/null +++ b/lib/providers/actions/common.dart @@ -0,0 +1,84 @@ +part of '../action.dart'; + +@Riverpod(keepAlive: true) +class CommonAction extends _$CommonAction { + @override + void build() {} + + void toggleRunning() { + final running = !ref.read(isStartProvider); + ref + .read(setupActionProvider.notifier) + .setRunning(running, initialize: running && !ref.read(initProvider)); + } + + void updateSpeedStatistics() { + ref + .read(appSettingProvider.notifier) + .update((state) => state.copyWith(showTrayTitle: !state.showTrayTitle)); + } + + void updateMode() { + ref.read(patchClashConfigProvider.notifier).update((state) { + final index = Mode.values.indexWhere((item) => item == state.mode); + if (index == -1) return state; + final nextIndex = index + 1 > Mode.values.length - 1 ? 0 : index + 1; + return state.copyWith(mode: Mode.values[nextIndex]); + }); + } + + Future updateTraffic() async { + final onlyStatisticsProxy = ref.read( + appSettingProvider.select((state) => state.onlyStatisticsProxy), + ); + final traffic = await coreController.getTraffic(onlyStatisticsProxy); + ref.read(trafficsProvider.notifier).addTraffic(traffic); + ref.read(totalTrafficProvider.notifier).value = await coreController + .getTotalTraffic(onlyStatisticsProxy); + } + + Future autoCheckUpdate() async { + if (!ref.read(appSettingProvider).autoCheckUpdate) return; + final res = await request.checkForUpdate(); + checkUpdateResultHandle(data: res); + } + + Future checkUpdateResultHandle({ + Map? data, + bool isUser = false, + }) async { + if (data != null) { + final tagName = data['tag_name']; + final body = data['body']; + final submits = utils.parseReleaseBody(body); + final context = globalState.navigatorKey.currentContext!; + final textTheme = context.textTheme; + final res = await globalState.showMessage( + title: currentAppLocalizations.discoverNewVersion, + message: TextSpan( + text: '$tagName \n', + style: textTheme.headlineSmall, + children: [ + TextSpan(text: '\n', style: textTheme.bodyMedium), + for (final submit in submits) + TextSpan(text: '- $submit \n', style: textTheme.bodyMedium), + ], + ), + confirmText: currentAppLocalizations.goDownload, + cancelText: isUser ? null : currentAppLocalizations.noLongerRemind, + ); + if (res == true) { + launchUrl(Uri.parse('https://github.com/$repository/releases/latest')); + } else if (!isUser && res == false) { + ref + .read(appSettingProvider.notifier) + .update((state) => state.copyWith(autoCheckUpdate: false)); + } + } else if (isUser) { + globalState.showMessage( + title: currentAppLocalizations.checkUpdate, + message: TextSpan(text: currentAppLocalizations.checkUpdateError), + ); + } + } +} diff --git a/lib/providers/actions/core.dart b/lib/providers/actions/core.dart new file mode 100644 index 0000000000..b3c4c7a62a --- /dev/null +++ b/lib/providers/actions/core.dart @@ -0,0 +1,83 @@ +part of '../action.dart'; + +@Riverpod(keepAlive: true) +class CoreAction extends _$CoreAction { + int _requestedRestartRevision = 0; + Future? _restartOperation; + bool _latestExplicitStart = false; + + @override + void build() {} + + Future initCore() async { + final isInit = await coreController.isInit; + + final version = ref.read(versionProvider); + if (!isInit) { + final res = await coreController.init(version); + commonPrint.log('init result: $res'); + } else { + await ref.read(proxiesActionProvider.notifier).updateGroups(); + } + } + + Future startCore() async { + ref.read(coreStatusProvider.notifier).value = CoreStatus.connecting; + try { + await coreController.start(); + ref.read(coreStatusProvider.notifier).value = CoreStatus.connected; + await initCore(); + } catch (error) { + ref.read(coreStatusProvider.notifier).value = CoreStatus.disconnected; + globalState.showNotifier(error.toString()); + } + } + + @protected + Future restartLifecycle() { + return coreController.restart(); + } + + Future restartCore([bool start = false]) { + _requestedRestartRevision++; + _latestExplicitStart = start; + final activeOperation = _restartOperation; + if (activeOperation != null) { + return activeOperation; + } + + final operation = _runRestartWorker(); + _restartOperation = operation; + return operation; + } + + Future _runRestartWorker() async { + try { + ref.read(coreStatusProvider.notifier).value = CoreStatus.connecting; + await restartLifecycle(); + ref.read(coreStatusProvider.notifier).value = CoreStatus.connected; + await initCore(); + + var appliedRevision = 0; + while (appliedRevision < _requestedRestartRevision) { + final revision = _requestedRestartRevision; + final explicitStart = _latestExplicitStart; + if (explicitStart || ref.read(isStartProvider)) { + await ref + .read(setupActionProvider.notifier) + .setRunning(true, initialize: true); + } else { + await ref + .read(setupActionProvider.notifier) + .applyProfile(force: true); + } + appliedRevision = revision; + } + } catch (_) { + ref.read(coreStatusProvider.notifier).value = CoreStatus.disconnected; + rethrow; + } finally { + _restartOperation = null; + } + } +} diff --git a/lib/providers/actions/geo_resource.dart b/lib/providers/actions/geo_resource.dart new file mode 100644 index 0000000000..13d3d77a96 --- /dev/null +++ b/lib/providers/actions/geo_resource.dart @@ -0,0 +1,20 @@ +part of '../action.dart'; + +@Riverpod(keepAlive: true) +class GeoResourceAction extends _$GeoResourceAction { + @override + void build() {} + + Future updateGeoResource(GeoResource geoResource) async { + await coreController.updateGeoData(geoResource.name); + } + + void updateGeoResourceUrl(GeoResource geoResource, String newUrl) { + if (!newUrl.isUrl) { + throw 'Invalid url'; + } + ref.read(patchClashConfigProvider.notifier).update((state) { + return state.copyWith(geoXUrl: {...state.geoXUrl, geoResource: newUrl}); + }); + } +} diff --git a/lib/providers/actions/profiles.dart b/lib/providers/actions/profiles.dart new file mode 100644 index 0000000000..9401a9c25a --- /dev/null +++ b/lib/providers/actions/profiles.dart @@ -0,0 +1,151 @@ +part of '../action.dart'; + +@Riverpod(keepAlive: true) +class ProfilesAction extends _$ProfilesAction { + @override + void build() {} + + void updateCurrentSelectedMap(String groupName, String proxyName) { + final currentProfile = ref.read(currentProfileProvider); + if (currentProfile != null && + currentProfile.selectedMap[groupName] != proxyName) { + final selectedMap = Map.from(currentProfile.selectedMap) + ..[groupName] = proxyName; + ref + .read(profilesProvider.notifier) + .put(currentProfile.copyWith(selectedMap: selectedMap)); + } + } + + Future deleteProfile(int id) async { + await ref.read(profilesProvider.notifier).del(id); + await clearEffect(id); + final currentProfileId = ref.read(currentProfileIdProvider); + if (currentProfileId == id) { + final profiles = ref.read(profilesProvider); + if (profiles.isNotEmpty) { + final updateId = profiles.first.id; + ref.read(currentProfileIdProvider.notifier).value = updateId; + } else { + ref.read(currentProfileIdProvider.notifier).value = null; + ref.read(setupActionProvider.notifier).setRunning(false); + } + } + } + + Future autoUpdateProfiles() async { + for (final profile in ref.read(profilesProvider)) { + if (!profile.autoUpdate) continue; + final isNotNeedUpdate = profile.lastUpdateDate + ?.add(profile.autoUpdateDuration) + .isBeforeNow; + if (isNotNeedUpdate == false || profile.type == ProfileType.file) { + continue; + } + try { + await updateProfile(profile); + } catch (e) { + commonPrint.log(e.toString(), logLevel: LogLevel.warning); + } + } + } + + void putProfile(Profile profile) { + ref.read(profilesProvider.notifier).put(profile); + if (ref.read(currentProfileIdProvider) != null) return; + ref.read(currentProfileIdProvider.notifier).value = profile.id; + } + + Future updateProfiles() async { + for (final profile in ref.read(profilesProvider)) { + if (profile.type == ProfileType.file) continue; + await updateProfile(profile); + } + } + + Future updateProfile( + Profile profile, { + bool showLoading = false, + }) async { + try { + if (showLoading) { + ref.read(isUpdatingProvider(profile.updatingKey).notifier).value = true; + } + ref.read(profilesProvider.notifier).put(profile); + final newProfile = await profile.update(); + ref.read(profilesProvider.notifier).put(newProfile); + if (profile.id == ref.read(currentProfileIdProvider)) { + ref + .read(setupActionProvider.notifier) + .applyProfileDebounce(silence: true); + } + } finally { + ref.read(isUpdatingProvider(profile.updatingKey).notifier).value = false; + } + } + + Future addProfileFormFile() async { + final platformFile = await globalState.safeRun(picker.pickerFile); + if (platformFile == null) return; + final bytes = await platformFile.readBytes(); + globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst); + ref.read(currentPageLabelProvider.notifier).toProfiles(); + final profile = await globalState.loadingRun( + tag: LoadingTag.profiles, + () async { + return Profile.normal(label: platformFile.name).saveFile(bytes); + }, + title: currentAppLocalizations.addProfile, + ); + if (profile != null) { + putProfile(profile); + } + } + + Future addProfileFormURL(String url) async { + if (globalState.navigatorKey.currentState?.canPop() ?? false) { + globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst); + } + ref.read(currentPageLabelProvider.notifier).value = PageLabel.profiles; + final profile = await globalState.loadingRun( + tag: LoadingTag.profiles, + () async { + return Profile.normal(url: url).update(); + }, + title: currentAppLocalizations.addProfile, + ); + if (profile != null) { + putProfile(profile); + } + } + + void setProfileAndAutoApply(Profile profile) { + ref.read(profilesProvider.notifier).put(profile); + if (profile.id == ref.read(currentProfileIdProvider)) { + ref.read(setupActionProvider.notifier).applyProfileDebounce(); + } + } + + Future addProfileFormQrCode() async { + final url = await globalState.safeRun(picker.pickerConfigQRCode); + if (url == null) return; + addProfileFormURL(url); + } + + void reorder(List profiles) { + ref.read(profilesProvider.notifier).reorder(profiles); + } + + Future clearEffect(int profileId) async { + final profilePath = await appPath.getProfilePath(profileId.toString()); + final profileFile = File(profilePath); + final isExists = await profileFile.exists(); + if (isExists) { + await profileFile.safeDelete(recursive: true); + } + final error = await coreController.clearEffect(profileId); + if (error.isNotEmpty) { + commonPrint.log(error, logLevel: LogLevel.warning); + } + } +} diff --git a/lib/providers/actions/proxies.dart b/lib/providers/actions/proxies.dart new file mode 100644 index 0000000000..27a937c813 --- /dev/null +++ b/lib/providers/actions/proxies.dart @@ -0,0 +1,108 @@ +part of '../action.dart'; + +@Riverpod(keepAlive: true) +class ProxiesAction extends _$ProxiesAction { + @override + void build() {} + + void updateGroupsDebounce([Duration? duration]) { + debouncer.call(FunctionTag.updateGroups, updateGroups, duration: duration); + } + + void changeProxyDebounce(String groupName, String proxyName) { + debouncer.call(FunctionTag.changeProxy, ( + String groupName, + String proxyName, + ) async { + await changeProxy(groupName: groupName, proxyName: proxyName); + updateGroupsDebounce(); + }, args: [groupName, proxyName]); + } + + Future updateGroups() async { + try { + commonPrint.log('updateGroups'); + ref.read(groupsProvider.notifier).value = await retry( + task: () async { + final sortType = ref.read( + proxiesStyleSettingProvider.select((state) => state.sortType), + ); + final delayMap = ref.read(delayDataSourceProvider); + final testUrl = ref.read( + appSettingProvider.select((state) => state.testUrl), + ); + final selectedMap = ref.read( + currentProfileProvider.select((state) => state?.selectedMap ?? {}), + ); + return coreController.getProxiesGroups( + selectedMap: selectedMap, + sortType: sortType, + delayMap: delayMap, + defaultTestUrl: testUrl, + ); + }, + retryIf: (res) => res.isEmpty, + ); + } catch (e) { + commonPrint.log('updateGroups error: $e'); + ref.read(groupsProvider.notifier).value = []; + } + } + + void updateCurrentGroupName(String groupName) { + final profile = ref.read(currentProfileProvider); + if (profile == null || profile.currentGroupName == groupName) return; + ref + .read(profilesProvider.notifier) + .put(profile.copyWith(currentGroupName: groupName)); + } + + void updateCurrentUnfoldSet(Set value) { + final currentProfile = ref.read(currentProfileProvider); + if (currentProfile == null) return; + ref + .read(profilesProvider.notifier) + .put(currentProfile.copyWith(unfoldSet: value)); + } + + void setDelay(Delay delay) { + ref.read(delayDataSourceProvider.notifier).setDelay(delay); + } + + Future changeProxy({ + required String groupName, + required String proxyName, + }) async { + await coreController.changeProxy( + ChangeProxyParams(groupName: groupName, proxyName: proxyName), + ); + if (ref.read(appSettingProvider).closeConnections) { + await coreController.closeConnections(); + } else { + await coreController.resetConnections(); + } + ref.read(checkIpNumProvider.notifier).add(); + } + + Future updateProvider( + ExternalProvider provider, { + bool showLoading = false, + }) async { + try { + if (showLoading) { + ref.read(isUpdatingProvider(provider.updatingKey).notifier).value = + true; + } + final message = await coreController.updateExternalProvider( + providerName: provider.name, + ); + if (message.isNotEmpty) return message; + ref + .read(providersProvider.notifier) + .setProvider(await coreController.getExternalProvider(provider.name)); + return ''; + } finally { + ref.read(isUpdatingProvider(provider.updatingKey).notifier).value = false; + } + } +} diff --git a/lib/providers/actions/setup.dart b/lib/providers/actions/setup.dart new file mode 100644 index 0000000000..28aac64f90 --- /dev/null +++ b/lib/providers/actions/setup.dart @@ -0,0 +1,432 @@ +part of '../action.dart'; + +enum _SetupTaskResult { completed, handoffToCoreRestart } + +class _RunRequest { + final bool running; + final bool initialize; + + const _RunRequest({required this.running, required this.initialize}); +} + +@Riverpod(keepAlive: true) +class SetupAction extends _$SetupAction { + Timer? _runtimeTimer; + final _setupScheduler = SerialTaskScheduler(); + final _listenerScheduler = SerialTaskScheduler(); + _RunRequest? _latestRunRequest; + DateTime? _startTime; + + bool get _isRunning => _startTime != null && _startTime!.isBeforeNow; + + @override + void build() {} + + SetupParams get _setupParams { + final selectedMap = ref.read(selectedMapProvider); + final testUrl = ref.read( + appSettingProvider.select((state) => state.testUrl), + ); + return SetupParams(selectedMap: selectedMap, testUrl: testUrl); + } + + void fullSetup() { + if (!ref.read(initProvider)) return; + ref.read(delayDataSourceProvider.notifier).value = {}; + unawaited(_runSetup(force: true)); + ref.read(logsProvider.notifier).value = FixedList(500); + ref.read(requestsProvider.notifier).value = FixedList(500); + } + + void _setLocalRunning(bool running) { + _runtimeTimer?.cancel(); + _runtimeTimer = null; + if (!running) { + _startTime = null; + debouncer.cancel(FunctionTag.applyProfile); + _updateRunTime(); + return; + } + + _startTime ??= DateTime.now(); + _refreshRunningState(); + _runtimeTimer = Timer.periodic( + const Duration(seconds: 1), + (_) => _refreshRunningState(), + ); + } + + void _refreshRunningState() { + _updateRunTime(); + unawaited(ref.read(commonActionProvider.notifier).updateTraffic()); + } + + void _updateRunTime() { + final startTime = _startTime; + ref.read(runTimeProvider.notifier).value = startTime == null + ? null + : DateTime.now().millisecondsSinceEpoch - + startTime.millisecondsSinceEpoch; + } + + Future _updateStartTime() async { + _startTime = await service?.getRunTime(); + } + + Future initStatus() async { + if (!globalState.needInitStatus) { + commonPrint.log('init status cancel'); + return; + } + commonPrint.log('init status'); + if (system.isAndroid) { + await _updateStartTime(); + } + final shouldRun = _isRunning || ref.read(appSettingProvider).autoRun; + if (shouldRun) { + await setRunning(true, initialize: true); + } else { + await applyProfile(force: true); + } + } + + Future setRunning(bool running, {bool initialize = false}) { + if (running && !initialize && !ref.read(initProvider)) { + return Future.value(); + } + + final request = _RunRequest( + running: running, + initialize: running && initialize, + ); + _latestRunRequest = request; + _setLocalRunning(running); + if (request.initialize) { + globalState.needInitStatus = false; + } + return running ? _start(request) : _stop(request); + } + + Future _start(_RunRequest request) async { + if (request.initialize) { + try { + await applyProfile( + force: true, + preloadInvoke: () => _setCoreRunning(request), + ); + } catch (_) { + if (_isCurrent(request)) { + await setRunning(false); + } + } + return; + } + + await _setCoreRunning(request); + if (_isCurrent(request)) { + applyProfileDebounce(force: true, silence: true); + } + } + + Future _stop(_RunRequest request) async { + await _setCoreRunning(request); + if (!_isCurrent(request)) { + return; + } + resetCoreTraffic(); + ref.read(trafficsProvider.notifier).clear(); + ref.read(totalTrafficProvider.notifier).value = const Traffic(); + ref.read(checkIpNumProvider.notifier).add(); + } + + Future _setCoreRunning(_RunRequest request) { + return _listenerScheduler.run(() async { + if (!_isCurrent(request)) { + return; + } + if (request.running && ref.read(suspendProvider)) { + return; + } + await setCoreRunning(request.running); + }); + } + + bool _isCurrent(_RunRequest request) => identical(_latestRunRequest, request); + + Future updateConfigDebounce() async { + debouncer.call(FunctionTag.updateConfig, updateConfig); + } + + @protected + Future setCoreRunning(bool running) { + return running + ? coreController.startListener() + : coreController.stopListener(); + } + + @protected + void resetCoreTraffic() { + coreController.resetTraffic(); + } + + @visibleForTesting + Future updateConfig() async { + await globalState.safeRun(() async { + final updateParams = ref.read(updateParamsProvider); + final shouldContinueSetup = await requestAdmin(updateParams.tun.enable); + if (!shouldContinueSetup) { + await _restartCoreAfterAuthorization(); + return; + } + final message = await coreController.updateConfig( + updateParams.copyWith.tun( + enable: _getEffectiveTunEnable(updateParams.tun.enable), + ), + ); + ref.read(checkIpNumProvider.notifier).add(); + if (message.isNotEmpty) throw message; + }); + } + + void tryCheckIp() { + final isTimeout = ref.read( + networkDetectionProvider.select( + (state) => state.ipInfo == null && state.isLoading == false, + ), + ); + if (!isTimeout) return; + ref.read(checkIpNumProvider.notifier).add(); + } + + void applyProfileDebounce({bool silence = false, bool force = false}) { + debouncer.call(FunctionTag.applyProfile, (silence, force) { + applyProfile(silence: silence, force: force); + }, args: [silence, force]); + } + + void changeMode(Mode mode) { + ref + .read(patchClashConfigProvider.notifier) + .update((state) => state.copyWith(mode: mode)); + if (mode == Mode.global) { + ref + .read(proxiesActionProvider.notifier) + .updateCurrentGroupName(GroupName.GLOBAL.name); + } + } + + void autoApplyProfile() { + WidgetsBinding.instance.addPostFrameCallback((_) { + applyProfile(); + }); + } + + Future applyProfile({ + bool silence = false, + bool force = false, + Future Function()? preloadInvoke, + }) { + return _runSetup( + force: force, + silence: silence, + preloadInvoke: preloadInvoke, + ); + } + + Future _runSetup({ + bool silence = false, + bool force = false, + Future Function()? preloadInvoke, + }) async { + final result = await _setupScheduler.run(() { + return _setupConfig( + force: force, + silence: silence, + preloadInvoke: preloadInvoke, + onUpdated: () async { + await ref.read(proxiesActionProvider.notifier).updateGroups(); + await ref.read(providersProvider.notifier).syncProviders(); + }, + ); + }); + if (result != _SetupTaskResult.handoffToCoreRestart) { + return; + } + // Release the current serial task before restartCore reapplies the profile. + await _restartCoreAfterAuthorization(); + } + + Future _restartCoreAfterAuthorization() async { + try { + await ref.read(coreActionProvider.notifier).restartCore(); + } catch (_) { + ref.read(authorizedTunEnableProvider.notifier).value = + TunAuthorizationState.unauthorized; + } + } + + Future> getProfile({ + required SetupState setupState, + required PatchClashConfig patchConfig, + }) async { + final profileId = setupState.profileId; + if (profileId == null) return const VM2('', ''); + final defaultUA = globalState.packageInfo.ua; + final networkVM2 = ref.read( + networkSettingProvider.select( + (state) => VM2(state.appendSystemDns, state.routeMode), + ), + ); + final overrideDns = ref.read(overrideDnsProvider); + final appendSystemDns = networkVM2.a; + final routeMode = networkVM2.b; + final configMap = await coreController.getConfig(profileId); + String? scriptContent; + final List addedRules = []; + final List proxyGroups = []; + final List rules = []; + if (setupState.overwriteType == OverwriteType.script) { + scriptContent = await setupState.script?.content; + } else if (setupState.overwriteType == OverwriteType.standard) { + addedRules.addAll(setupState.addedRules); + } else { + proxyGroups.addAll(setupState.proxyGroups); + rules.addAll(setupState.rules); + } + final realPatchConfig = patchConfig.copyWith( + tun: patchConfig.tun.getRealTun(routeMode), + ); + Map rawConfig = configMap; + if (scriptContent?.isNotEmpty == true) { + rawConfig = await handleEvaluate(scriptContent!, rawConfig); + } + final directory = await appPath.profilesPath; + final res = makeRealProfileTask( + MakeRealProfileState( + rules: rules, + proxyGroups: proxyGroups, + profilesPath: directory, + profileId: profileId, + rawConfig: rawConfig, + realPatchConfig: realPatchConfig, + overrideDns: overrideDns, + appendSystemDns: appendSystemDns, + addedRules: addedRules, + defaultUA: defaultUA, + ), + ); + return res; + } + + Future getProfileWithId(int profileId) async { + try { + final setupState = await ref.read(setupStateProvider(profileId).future); + final patchClashConfig = ref.read(patchClashConfigProvider); + final res = await getProfile( + setupState: setupState, + patchConfig: patchClashConfig, + ); + return res.a; + } catch (e) { + globalState.showNotifier(e.toString()); + } + return ''; + } + + bool _getEffectiveTunEnable(bool enableTun) { + final authorizationState = ref.read(authorizedTunEnableProvider); + return enableTun && authorizationState == TunAuthorizationState.authorized; + } + + @protected + Future authorizeCore() { + return system.authorizeCore(); + } + + @visibleForTesting + Future requestAdmin(bool enableTun) async { + if (!enableTun) { + return true; + } + final authorizationState = ref.read(authorizedTunEnableProvider); + if (authorizationState == TunAuthorizationState.authorized) { + return true; + } + + final authorizationNotifier = ref.read( + authorizedTunEnableProvider.notifier, + ); + authorizationNotifier.value = TunAuthorizationState.unauthorized; + + final code = await authorizeCore(); + + switch (code) { + case AuthorizeCode.success: + authorizationNotifier.value = TunAuthorizationState.authorized; + return false; + case AuthorizeCode.none: + authorizationNotifier.value = TunAuthorizationState.authorized; + return true; + case AuthorizeCode.error: + return true; + } + } + + Future<_SetupTaskResult> _setupConfig({ + bool force = false, + bool silence = false, + Future Function()? preloadInvoke, + FutureOr Function()? onUpdated, + }) async { + var profile = ref.read(currentProfileProvider); + final nextProfile = await profile?.checkAndUpdateAndCopy(); + if (nextProfile != null) { + profile = nextProfile; + ref.read(profilesProvider.notifier).put(nextProfile); + } + commonPrint.log('setup ===> ${profile?.realLabel}'); + final patchConfig = ref.read(patchClashConfigProvider); + final shouldContinueSetup = await requestAdmin(patchConfig.tun.enable); + if (!shouldContinueSetup) { + return _SetupTaskResult.handoffToCoreRestart; + } + final effectiveTunEnable = _getEffectiveTunEnable(patchConfig.tun.enable); + final realPatchConfig = patchConfig.copyWith.tun( + enable: effectiveTunEnable, + ); + final setupState = await ref.read(setupStateProvider(profile?.id).future); + final vm2 = await getProfile( + setupState: setupState, + patchConfig: realPatchConfig, + ); + final yamlString = vm2.a; + final yamlMd5 = vm2.b; + if (yamlMd5 == globalState.lastConfigMd5 && force == false) { + return _SetupTaskResult.completed; + } + if (system.isAndroid) { + globalState.lastVpnState = ref.read(vpnStateProvider); + final sharedState = ref.read(sharedStateProvider); + await preferences.saveShareState(sharedState); + } + await globalState.loadingRun( + () async { + final configFilePath = await appPath.configFilePath; + await File(configFilePath).safeWriteAsString(yamlString); + final message = await coreController.setupConfig( + params: _setupParams, + preloadInvoke: preloadInvoke, + ); + if (message.isNotEmpty && !message.endsWith('is empty')) { + throw message; + } + globalState.lastConfigMd5 = yamlMd5; + ref.read(checkIpNumProvider.notifier).add(); + await onUpdated?.call(); + }, + silence: true, + tag: !silence ? LoadingTag.proxies : null, + ); + return _SetupTaskResult.completed; + } +} diff --git a/lib/providers/actions/store.dart b/lib/providers/actions/store.dart new file mode 100644 index 0000000000..f8993cdde5 --- /dev/null +++ b/lib/providers/actions/store.dart @@ -0,0 +1,58 @@ +part of '../action.dart'; + +@Riverpod(keepAlive: true) +class StoreAction extends _$StoreAction { + @override + void build() {} + + Future shakingStore() async { + final profileIds = ref.read( + profilesProvider.select((state) => state.map((item) => item.id)), + ); + final scriptIds = await ref.read( + scriptsProvider.future.select( + (state) async => (await state).map((item) => item.id), + ), + ); + final pathsToDelete = await shakingProfileTask(VM2(profileIds, scriptIds)); + await Future.wait( + pathsToDelete.map((path) => File(path).safeDelete(recursive: true)), + ); + } + + void savePreferencesDebounce() { + debouncer.call(FunctionTag.savePreferences, () async { + await preferences.saveConfig(ref.read(configProvider)); + }); + } + + Future handleClear() async { + final profileIds = ref + .read(profilesProvider) + .map((item) => item.id) + .toSet(); + final providersDir = Directory(await appPath.getProvidersRootPath()); + if (await providersDir.exists()) { + await for (final entity in providersDir.list(followLinks: false)) { + if (entity is! Directory) continue; + final profileId = int.tryParse(basename(entity.path)); + if (profileId != null && profileId > 0) { + profileIds.add(profileId); + } + } + } + final clearResults = await Future.wait( + profileIds.map(coreController.clearEffect), + ); + for (final error in clearResults.where((error) => error.isNotEmpty)) { + commonPrint.log(error, logLevel: LogLevel.warning); + } + await preferences.clearPreferences(); + commonPrint.log('clear preferences'); + await database.close(); + await File(await appPath.databasePath).safeDelete(recursive: true); + await Directory(await appPath.profilesPath).safeDelete(recursive: true); + await preferences.clearPreferences(); + ref.read(systemActionProvider.notifier).handleExit(false); + } +} diff --git a/lib/providers/actions/system.dart b/lib/providers/actions/system.dart new file mode 100644 index 0000000000..8518c9ab66 --- /dev/null +++ b/lib/providers/actions/system.dart @@ -0,0 +1,117 @@ +part of '../action.dart'; + +@Riverpod(keepAlive: true) +class SystemAction extends _$SystemAction { + SystemExitCoordinator? _exitCoordinator; + + @override + void build() {} + + Future> getPackages() async { + if (ref.read(isMobileViewProvider)) { + await Future.delayed(commonDuration); + } + if (ref.read(packagesProvider).isEmpty) { + ref.read(packagesProvider.notifier).value = + await app?.getPackages() ?? []; + } + return ref.read(packagesProvider); + } + + Future handleExit([bool needSave = false]) { + final coordinator = _exitCoordinator ??= SystemExitCoordinator( + watchdogDuration: exitWatchdogDuration, + closeWindow: closeWindow, + closeCore: closeCore, + exitApplication: exitApplication, + ); + return coordinator.exit(cleanup: () => cleanupExitResources(needSave)); + } + + @protected + Duration get exitWatchdogDuration => const Duration(seconds: 3); + + @protected + Future cleanupExitResources(bool needSave) async { + await Future.wait([ + if (needSave) preferences.saveConfig(ref.read(configProvider)), + if (macOS != null) macOS!.updateDns(true), + if (proxy != null) proxy!.stopProxy(), + if (tray != null) tray!.destroy(), + ]); + } + + @protected + Future closeWindow() async { + await window?.close(); + } + + @protected + Future closeCore() async { + await coreController.close(); + commonPrint.log('exit'); + } + + @protected + Future exitApplication() { + return system.exit(); + } + + Future handleClose([bool exit = true]) async { + if (!system.isDesktop) { + if (ref.read(backBlockProvider)) return; + } + if (ref.read(appSettingProvider).minimizeOnExit || !exit) { + if (system.isDesktop) { + await preferences.saveConfig(ref.read(configProvider)); + } + await system.back(); + } else { + await handleExit(); + } + } + + Future updateVisible() async { + final visible = await window?.isVisible; + if (visible != null && !visible) { + window?.show(); + } else { + window?.hide(); + } + } + + void updateTun() { + ref + .read(patchClashConfigProvider.notifier) + .update((state) => state.copyWith.tun(enable: !state.tun.enable)); + } + + void updateSystemProxy() { + ref + .read(networkSettingProvider.notifier) + .update((state) => state.copyWith(systemProxy: !state.systemProxy)); + } + + void updateAutoLaunch() { + ref + .read(appSettingProvider.notifier) + .update((state) => state.copyWith(autoLaunch: !state.autoLaunch)); + } + + Future updateTray() async { + tray?.update( + trayState: ref.read(trayStateProvider), + traffic: ref.read( + trafficsProvider.select( + (state) => state.list.safeLast(const Traffic()), + ), + ), + ); + } + + Future updateLocalIp() async { + ref.read(localIpProvider.notifier).value = null; + await Future.delayed(commonDuration); + ref.read(localIpProvider.notifier).value = await utils.getLocalIpAddress(); + } +} diff --git a/lib/providers/actions/system_exit.dart b/lib/providers/actions/system_exit.dart new file mode 100644 index 0000000000..5bb02418a1 --- /dev/null +++ b/lib/providers/actions/system_exit.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +typedef ExitStep = Future Function(); + +final class SystemExitCoordinator { + final Duration watchdogDuration; + final ExitStep closeWindow; + final ExitStep closeCore; + final ExitStep exitApplication; + + Future? _operation; + Future? _applicationExitOperation; + + SystemExitCoordinator({ + required this.watchdogDuration, + required this.closeWindow, + required this.closeCore, + required this.exitApplication, + }); + + Future exit({required ExitStep cleanup}) { + final activeOperation = _operation; + if (activeOperation != null) { + return activeOperation; + } + final operation = _run(cleanup); + _operation = operation; + return operation; + } + + Future _run(ExitStep cleanup) async { + Object? firstError; + StackTrace? firstStackTrace; + + Future runStep(ExitStep step) async { + try { + await step(); + } catch (error, stackTrace) { + firstError ??= error; + firstStackTrace ??= stackTrace; + } + } + + final watchdog = Timer(watchdogDuration, () { + unawaited(_exitApplicationOnce().catchError((_) {})); + }); + try { + await runStep(cleanup); + await runStep(closeWindow); + await runStep(closeCore); + } finally { + watchdog.cancel(); + await runStep(_exitApplicationOnce); + } + final error = firstError; + if (error != null) { + Error.throwWithStackTrace(error, firstStackTrace!); + } + } + + Future _exitApplicationOnce() { + final activeOperation = _applicationExitOperation; + if (activeOperation != null) { + return activeOperation; + } + final operation = Future.sync(exitApplication); + _applicationExitOperation = operation; + return operation; + } +} diff --git a/lib/providers/actions/theme.dart b/lib/providers/actions/theme.dart new file mode 100644 index 0000000000..6b48260e00 --- /dev/null +++ b/lib/providers/actions/theme.dart @@ -0,0 +1,20 @@ +part of '../action.dart'; + +@Riverpod(keepAlive: true) +class ThemeAction extends _$ThemeAction { + @override + void build() {} + + void updateBrightness() { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(systemBrightnessProvider.notifier).value = + WidgetsBinding.instance.platformDispatcher.platformBrightness; + }); + } + + void updateViewSize(Size size) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(viewSizeProvider.notifier).value = size; + }); + } +} diff --git a/lib/providers/app.dart b/lib/providers/app.dart index 725537c05b..fb0b066548 100644 --- a/lib/providers/app.dart +++ b/lib/providers/app.dart @@ -13,11 +13,12 @@ import 'package:wifi_ssid/wifi_ssid.dart'; part 'generated/app.g.dart'; -@riverpod -class RealTunEnable extends _$RealTunEnable with AutoDisposeNotifierMixin { +@Riverpod(keepAlive: true) +class AuthorizedTunEnable extends _$AuthorizedTunEnable + with AutoDisposeNotifierMixin { @override - bool build() { - return false; + TunAuthorizationState build() { + return TunAuthorizationState.none; } } @@ -481,7 +482,9 @@ List buildAppStateOverrides(AppState appState) { logsProvider.overrideWithBuild((_, _) => appState.logs), trafficsProvider.overrideWithBuild((_, _) => appState.traffics), totalTrafficProvider.overrideWithBuild((_, _) => appState.totalTraffic), - realTunEnableProvider.overrideWithBuild((_, _) => appState.realTunEnable), + authorizedTunEnableProvider.overrideWithBuild( + (_, _) => appState.authorizedTunEnable, + ), systemUiOverlayStyleStateProvider.overrideWithBuild( (_, _) => appState.systemUiOverlayStyle, ), diff --git a/lib/providers/database.dart b/lib/providers/database.dart index 1d0d512476..e5d1c239f9 100644 --- a/lib/providers/database.dart +++ b/lib/providers/database.dart @@ -63,15 +63,13 @@ class Profiles extends _$Profiles { ); } - void del(int id) { + Future del(int id) async { final previous = List.from(state); state = previous.where((e) => e.id != id).toList(); - unawaited( - withRollback( - snapshot: previous, - action: () => database.profiles.remove((t) => t.id.equals(id)), - rollback: (v) => state = v, - ), + await withRollback( + snapshot: previous, + action: () => database.profiles.remove((t) => t.id.equals(id)), + rollback: (v) => state = v, ); } diff --git a/lib/providers/generated/action.g.dart b/lib/providers/generated/action.g.dart index eac384e11a..d94052a56c 100644 --- a/lib/providers/generated/action.g.dart +++ b/lib/providers/generated/action.g.dart @@ -40,13 +40,13 @@ final class CommonActionProvider extends $NotifierProvider { } } -String _$commonActionHash() => r'e2a7aa2c41c9404133b16a111b2182357dee4d6e'; +String _$commonActionHash() => r'81d01cab066e94793cdaa4ff89806ebff6030f50'; abstract class _$CommonAction extends $Notifier { void build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -56,7 +56,7 @@ abstract class _$CommonAction extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -91,13 +91,13 @@ final class SetupActionProvider extends $NotifierProvider { } } -String _$setupActionHash() => r'c6c7b3b5d90f5070dca9ed02e51dcc06f812a294'; +String _$setupActionHash() => r'27018fa3a8606b501472f50aa4b46eeb0c964ff3'; abstract class _$SetupAction extends $Notifier { void build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -107,7 +107,7 @@ abstract class _$SetupAction extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -142,13 +142,13 @@ final class BackupActionProvider extends $NotifierProvider { } } -String _$backupActionHash() => r'4953679dac7f99f6e076720a2a6f9750a22fd74f'; +String _$backupActionHash() => r'3c115d169b912577a3bd87796cefafce3695a019'; abstract class _$BackupAction extends $Notifier { void build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -158,7 +158,7 @@ abstract class _$BackupAction extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -193,13 +193,13 @@ final class CoreActionProvider extends $NotifierProvider { } } -String _$coreActionHash() => r'2b8d02ad5d8219e07bf721268639c619a93c39c1'; +String _$coreActionHash() => r'f07e30b5c450b0ed90b5e5fc7faa1113374f6516'; abstract class _$CoreAction extends $Notifier { void build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -209,7 +209,7 @@ abstract class _$CoreAction extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -250,7 +250,7 @@ abstract class _$SystemAction extends $Notifier { void build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -260,7 +260,7 @@ abstract class _$SystemAction extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -295,13 +295,13 @@ final class StoreActionProvider extends $NotifierProvider { } } -String _$storeActionHash() => r'45557218752e62f3a53ef7b68de7d0e22a8ecc0f'; +String _$storeActionHash() => r'e95aaafbf5be8d9d57be16ebc44bf3dcc556619d'; abstract class _$StoreAction extends $Notifier { void build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -311,7 +311,7 @@ abstract class _$StoreAction extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -352,7 +352,7 @@ abstract class _$ThemeAction extends $Notifier { void build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -362,7 +362,7 @@ abstract class _$ThemeAction extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -404,7 +404,7 @@ abstract class _$ProxiesAction extends $Notifier { void build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -414,7 +414,7 @@ abstract class _$ProxiesAction extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -450,13 +450,13 @@ final class ProfilesActionProvider } } -String _$profilesActionHash() => r'b2457dc5b18d51204d17995949a52cffce41df38'; +String _$profilesActionHash() => r'e67a3e4a98c1b3b668275721491a2b0318d27550'; abstract class _$ProfilesAction extends $Notifier { void build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -466,7 +466,7 @@ abstract class _$ProfilesAction extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -508,7 +508,7 @@ abstract class _$GeoResourceAction extends $Notifier { void build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -518,6 +518,6 @@ abstract class _$GeoResourceAction extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } diff --git a/lib/providers/generated/app.g.dart b/lib/providers/generated/app.g.dart index 213ea0947a..fb2b2e2e81 100644 --- a/lib/providers/generated/app.g.dart +++ b/lib/providers/generated/app.g.dart @@ -9,55 +9,56 @@ part of '../app.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning -@ProviderFor(RealTunEnable) -final realTunEnableProvider = RealTunEnableProvider._(); +@ProviderFor(AuthorizedTunEnable) +final authorizedTunEnableProvider = AuthorizedTunEnableProvider._(); -final class RealTunEnableProvider - extends $NotifierProvider { - RealTunEnableProvider._() +final class AuthorizedTunEnableProvider + extends $NotifierProvider { + AuthorizedTunEnableProvider._() : super( from: null, argument: null, retry: null, - name: r'realTunEnableProvider', - isAutoDispose: true, + name: r'authorizedTunEnableProvider', + isAutoDispose: false, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$realTunEnableHash(); + String debugGetCreateSourceHash() => _$authorizedTunEnableHash(); @$internal @override - RealTunEnable create() => RealTunEnable(); + AuthorizedTunEnable create() => AuthorizedTunEnable(); /// {@macro riverpod.override_with_value} - Override overrideWithValue(bool value) { + Override overrideWithValue(TunAuthorizationState value) { return $ProviderOverride( origin: this, - providerOverride: $SyncValueProvider(value), + providerOverride: $SyncValueProvider(value), ); } } -String _$realTunEnableHash() => r'f2c88f5031d1f97665c10f70121082c4f6d6c99d'; +String _$authorizedTunEnableHash() => + r'75958aeb341f93a4573209f7bae5057936d9700a'; -abstract class _$RealTunEnable extends $Notifier { - bool build(); +abstract class _$AuthorizedTunEnable extends $Notifier { + TunAuthorizationState build(); @$mustCallSuper @override - void runBuild() { - final ref = this.ref as $Ref; + WhenComplete runBuild() { + final ref = this.ref as $Ref; final element = ref.element as $ClassProviderElement< - AnyNotifier, - bool, + AnyNotifier, + TunAuthorizationState, Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -98,7 +99,7 @@ abstract class _$Logs extends $Notifier> { FixedList build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref, FixedList>; final element = ref.element @@ -108,7 +109,7 @@ abstract class _$Logs extends $Notifier> { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -150,7 +151,7 @@ abstract class _$Requests extends $Notifier> { FixedList build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref, FixedList>; final element = @@ -161,7 +162,7 @@ abstract class _$Requests extends $Notifier> { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -203,7 +204,7 @@ abstract class _$Providers extends $Notifier> { List build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref, List>; final element = @@ -214,7 +215,7 @@ abstract class _$Providers extends $Notifier> { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -256,7 +257,7 @@ abstract class _$Packages extends $Notifier> { List build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref, List>; final element = ref.element @@ -266,7 +267,7 @@ abstract class _$Packages extends $Notifier> { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -308,7 +309,7 @@ abstract class _$SystemBrightness extends $Notifier { Brightness build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -318,7 +319,7 @@ abstract class _$SystemBrightness extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -360,7 +361,7 @@ abstract class _$Traffics extends $Notifier> { FixedList build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref, FixedList>; final element = ref.element @@ -370,7 +371,7 @@ abstract class _$Traffics extends $Notifier> { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -412,7 +413,7 @@ abstract class _$TotalTraffic extends $Notifier { Traffic build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -422,7 +423,7 @@ abstract class _$TotalTraffic extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -463,7 +464,7 @@ abstract class _$LocalIp extends $Notifier { String? build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -473,7 +474,7 @@ abstract class _$LocalIp extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -514,7 +515,7 @@ abstract class _$RunTime extends $Notifier { int? build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -524,7 +525,7 @@ abstract class _$RunTime extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -565,7 +566,7 @@ abstract class _$ViewSize extends $Notifier { Size build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -575,7 +576,7 @@ abstract class _$ViewSize extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -616,7 +617,7 @@ abstract class _$SideWidth extends $Notifier { double build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -626,7 +627,7 @@ abstract class _$SideWidth extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -830,7 +831,7 @@ abstract class _$Init extends $Notifier { bool build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -840,7 +841,7 @@ abstract class _$Init extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -882,7 +883,7 @@ abstract class _$CurrentPageLabel extends $Notifier { PageLabel build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -892,7 +893,7 @@ abstract class _$CurrentPageLabel extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -933,7 +934,7 @@ abstract class _$SortNum extends $Notifier { int build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -943,7 +944,7 @@ abstract class _$SortNum extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -984,7 +985,7 @@ abstract class _$CheckIpNum extends $Notifier { int build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -994,7 +995,7 @@ abstract class _$CheckIpNum extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -1035,7 +1036,7 @@ abstract class _$BackBlock extends $Notifier { bool build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1045,7 +1046,7 @@ abstract class _$BackBlock extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -1086,7 +1087,7 @@ abstract class _$Version extends $Notifier { int build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1096,7 +1097,7 @@ abstract class _$Version extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -1137,7 +1138,7 @@ abstract class _$Groups extends $Notifier> { List build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref, List>; final element = ref.element @@ -1147,7 +1148,7 @@ abstract class _$Groups extends $Notifier> { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -1189,7 +1190,7 @@ abstract class _$DelayDataSource extends $Notifier { DelayMap build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1199,7 +1200,7 @@ abstract class _$DelayDataSource extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -1243,7 +1244,7 @@ abstract class _$SystemUiOverlayStyleState SystemUiOverlayStyle build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1253,7 +1254,7 @@ abstract class _$SystemUiOverlayStyleState Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -1295,7 +1296,7 @@ abstract class _$CoreStatus extends $Notifier { CoreStatus build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1305,7 +1306,7 @@ abstract class _$CoreStatus extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -1384,7 +1385,7 @@ abstract class _$Query extends $Notifier { String build(QueryTag tag); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1394,7 +1395,7 @@ abstract class _$Query extends $Notifier { Object?, Object? >; - element.handleCreate(ref, () => build(_$args)); + return element.handleCreate(ref, () => build(_$args)); } } @@ -1473,7 +1474,7 @@ abstract class _$Loading extends $Notifier { bool build(LoadingTag tag); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1483,7 +1484,7 @@ abstract class _$Loading extends $Notifier { Object?, Object? >; - element.handleCreate(ref, () => build(_$args)); + return element.handleCreate(ref, () => build(_$args)); } } @@ -1568,7 +1569,7 @@ abstract class _$Items extends $Notifier> { Set build(String key); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref, Set>; final element = ref.element @@ -1578,7 +1579,7 @@ abstract class _$Items extends $Notifier> { Object?, Object? >; - element.handleCreate(ref, () => build(_$args)); + return element.handleCreate(ref, () => build(_$args)); } } @@ -1656,7 +1657,7 @@ abstract class _$Item extends $Notifier { dynamic build(String key); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1666,7 +1667,7 @@ abstract class _$Item extends $Notifier { Object?, Object? >; - element.handleCreate(ref, () => build(_$args)); + return element.handleCreate(ref, () => build(_$args)); } } @@ -1745,7 +1746,7 @@ abstract class _$IsUpdating extends $Notifier { bool build(String name); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1755,7 +1756,7 @@ abstract class _$IsUpdating extends $Notifier { Object?, Object? >; - element.handleCreate(ref, () => build(_$args)); + return element.handleCreate(ref, () => build(_$args)); } } @@ -1797,7 +1798,7 @@ abstract class _$NetworkDetection extends $Notifier { NetworkDetectionState build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1807,7 +1808,7 @@ abstract class _$NetworkDetection extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -1849,7 +1850,7 @@ abstract class _$CurrentSSID extends $Notifier { String? build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1859,7 +1860,7 @@ abstract class _$CurrentSSID extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -1903,7 +1904,7 @@ abstract class _$BatteryOptimizationDisable extends $Notifier { bool build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1913,7 +1914,7 @@ abstract class _$BatteryOptimizationDisable extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -1956,7 +1957,7 @@ abstract class _$LocationPermissions extends $Notifier { WifiSsidPermission build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -1966,6 +1967,6 @@ abstract class _$LocationPermissions extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } diff --git a/lib/providers/generated/config.g.dart b/lib/providers/generated/config.g.dart index a86297a71e..5a2090b6df 100644 --- a/lib/providers/generated/config.g.dart +++ b/lib/providers/generated/config.g.dart @@ -47,7 +47,7 @@ abstract class _$AppSetting extends $Notifier { AppSettingProps build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -57,7 +57,7 @@ abstract class _$AppSetting extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -99,7 +99,7 @@ abstract class _$WindowSetting extends $Notifier { WindowProps build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -109,7 +109,7 @@ abstract class _$WindowSetting extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -150,7 +150,7 @@ abstract class _$VpnSetting extends $Notifier { VpnProps build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -160,7 +160,7 @@ abstract class _$VpnSetting extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -202,7 +202,7 @@ abstract class _$NetworkSetting extends $Notifier { NetworkProps build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -212,7 +212,7 @@ abstract class _$NetworkSetting extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -254,7 +254,7 @@ abstract class _$ThemeSetting extends $Notifier { ThemeProps build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -264,7 +264,7 @@ abstract class _$ThemeSetting extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -306,7 +306,7 @@ abstract class _$CurrentProfileId extends $Notifier { int? build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -316,7 +316,7 @@ abstract class _$CurrentProfileId extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -358,7 +358,7 @@ abstract class _$DavSetting extends $Notifier { DAVProps? build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -368,7 +368,7 @@ abstract class _$DavSetting extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -409,7 +409,7 @@ abstract class _$OverrideDns extends $Notifier { bool build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -419,7 +419,7 @@ abstract class _$OverrideDns extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -461,7 +461,7 @@ abstract class _$HotKeyActions extends $Notifier> { List build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref, List>; final element = ref.element @@ -471,7 +471,7 @@ abstract class _$HotKeyActions extends $Notifier> { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -514,7 +514,7 @@ abstract class _$ProxiesStyleSetting extends $Notifier { ProxiesStyleProps build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -524,7 +524,7 @@ abstract class _$ProxiesStyleSetting extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -566,7 +566,7 @@ abstract class _$PatchClashConfig extends $Notifier { PatchClashConfig build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref; final element = ref.element @@ -576,7 +576,7 @@ abstract class _$PatchClashConfig extends $Notifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -618,7 +618,7 @@ abstract class _$ExcludeSSIDs extends $Notifier> { List build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref, List>; final element = ref.element @@ -628,7 +628,7 @@ abstract class _$ExcludeSSIDs extends $Notifier> { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } diff --git a/lib/providers/generated/database.g.dart b/lib/providers/generated/database.g.dart index a1eea99555..cecb0fd243 100644 --- a/lib/providers/generated/database.g.dart +++ b/lib/providers/generated/database.g.dart @@ -292,13 +292,13 @@ final class ProfilesProvider } } -String _$profilesHash() => r'a977548501ae750bc4fcc0f59dc0a4994ced7c91'; +String _$profilesHash() => r'610b51558ceaf0dc12795756e9bd8e4f73880e22'; abstract class _$Profiles extends $Notifier> { List build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref, List>; final element = ref.element @@ -308,7 +308,7 @@ abstract class _$Profiles extends $Notifier> { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } @@ -342,7 +342,7 @@ abstract class _$Scripts extends $StreamNotifier> { Stream> build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref>, List