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..079726971a 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,8 +549,68 @@ "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" + "entriesCount": "{count} entries", + "features": "Features", + "hideAdvanced": "Hide advanced", + "showAdvanced": "Show advanced", + "tailscale": "Tailscale", + "tailscaleDesc": "Manage Tailscale outbound nodes", + "tailscaleEnable": "Enable Tailscale", + "tailscaleEnableDesc": "Inject Tailscale nodes as outbounds. Turning this off stops Tailscale from handling traffic; normal traffic is unaffected.", + "tailscaleEmptyTip": "No Tailscale nodes yet. Add one to route traffic through your tailnet.", + "addTailscaleNode": "Add Tailscale node", + "editTailscaleNode": "Edit Tailscale node", + "tailscaleAuthKey": "Auth key", + "tailscaleHostname": "Hostname", + "tailscaleControlUrl": "Control URL", + "tailscaleStateDir": "State directory", + "tailscaleEphemeral": "Ephemeral", + "tailscaleUdp": "UDP relay", + "tailscaleAcceptRoutes": "Accept routes", + "tailscaleExitNode": "Exit node", + "tailscaleExitNodeAllowLanAccess": "Allow LAN access via exit node", + "tailscaleNameExistsTip": "A node with this name already exists", + "tailscaleGuideTitle": "How Tailscale works", + "tailscaleAuthKeyHint": "From the Tailscale admin console → Settings → Keys. Required to authenticate the node.", + "tailscaleHostnameHint": "Optional. Device name shown in your tailnet.", + "tailscaleControlUrlHint": "Optional. Only for self-hosted control servers such as Headscale.", + "tailscaleStateDirHint": "Optional. Directory used to persist Tailscale state.", + "tailscaleExitNodeHint": "Optional. IP or name of a tailnet exit node to route all traffic through.", + "tailscaleBypass": "Keep Tailscale traffic direct", + "tailscaleRoutes": "Route destinations", + "tailscaleRoutesHint": "Domains or IPs sent through this node, one per line (e.g. your home PC's Tailscale IP or MagicDNS name).", + "tailscaleScenarioAndroidTitle": "Android client setup", + "tailscaleScenarioAndroidBody": "Keep FlClash as the only VPN. Do not run the Tailscale app VPN at the same time (Android allows only one). Use an embedded Tailscale node below, then add your home device to Route destinations.", + "tailscaleScenarioDesktopTitle": "Desktop / host setup", + "tailscaleScenarioDesktopBody": "You can run FlClash and the real Tailscale app together. Turn on \"Keep Tailscale traffic direct\" so FlClash does not hijack Tailscale's control plane or fake-IP DNS.", + "tailscaleAndroidStep1": "Get an auth key from the Tailscale admin console (Settings → Keys).", + "tailscaleAndroidStep2": "Add a node, paste the auth key, and put your home device IP or MagicDNS name in Route destinations.", + "tailscaleAndroidStep3": "Turn on Enable Tailscale. Leave \"Keep Tailscale traffic direct\" off unless the Tailscale app is also installed.", + "tailscaleAndroidStep4": "Start FlClash VPN, then tap the ping button on a node to verify the connection.", + "tailscaleDesktopStep1": "If this PC also runs the Tailscale app/service, turn on \"Keep Tailscale traffic direct\".", + "tailscaleDesktopStep2": "Optional: add an embedded Tailscale node with an auth key to route selected traffic through the tailnet from FlClash.", + "tailscaleDesktopStep3": "Put destinations (home IPs / MagicDNS) in Route destinations, then turn on Enable Tailscale.", + "tailscaleDesktopStep4": "Start FlClash, then tap the ping button on a node to verify the connection is solid.", + "tailscaleBypassRecommended": "Recommended on desktop when the Tailscale app/service is installed. Auto-manages DIRECT rules and Fake IP Filter.", + "tailscaleBypassAndroidHint": "Usually leave this off on Android. Turn on only if the Tailscale app is also installed on this phone.", + "tailscaleStatusDisabled": "Tailscale is off — nodes are not injected into the running profile.", + "tailscaleStatusNoNodes": "Enabled, but no nodes yet. Add a node to get started.", + "tailscaleStatusNeedStart": "Nodes are ready. Start FlClash VPN, then tap ping to test.", + "tailscaleStatusReady": "{count} node(s) active. Tap ping on a node to test connectivity.", + "tailscaleStatusNeedRoutes": "Nodes are added, but no route destinations yet — traffic will not match until you add routes (or pick the node manually).", + "tailscaleBypassNudge": "This device likely runs Tailscale already — turn on “Keep Tailscale traffic direct” to avoid fake-IP / control-plane breakage.", + "tailscaleShowSetupGuide": "Setup guide", + "tailscaleEnableBypassAction": "Enable", + "tailscaleNameHelper": "Outbound name used in Proxies. Renaming changes how selections and delay tests key this node.", + "tailscaleTestNeedEnable": "Turn on Enable Tailscale before testing.", + "tailscaleTestNeedStart": "Start FlClash VPN before testing the connection.", + "tailscaleTestNode": "Test connection", + "tailscaleNotTested": "Not tested", + "tailscaleNoRoutes": "No route destinations", + "tailscaleRoutesCount": "{count} route(s)", + "tailscaleNodesTitle": "Nodes", + "tailscaleTestTip": "Use the ping button next to a node to check whether the Tailscale outbound can dial out. A latency value means the connection is working; Timeout means check the auth key, Enable switch, and that FlClash VPN is started." } diff --git a/arb/intl_ja.arb b/arb/intl_ja.arb index a310126c0e..7285111fde 100644 --- a/arb/intl_ja.arb +++ b/arb/intl_ja.arb @@ -318,6 +318,8 @@ "messageTest": "メッセージテスト", "messageTestTip": "これはメッセージです。", "crashTest": "クラッシュテスト", + "crashDetected": "クラッシュを検出しました", + "crashDetectedTip": "前回の実行中にアプリがクラッシュしました。クラッシュの繰り返しを防ぐため、現在のプロファイルを解除し、設定の自動セットアップをスキップしました。", "clearData": "データを消去", "textScale": "テキストスケーリング", "internet": "インターネット", @@ -547,8 +549,68 @@ "hoursCount": "{count} 時間", "geoResources": "Geoリソース", "geoUpdating": "{name}を更新中...", - "geoSkipped": "{name} スキップ済み", + "geoSkipped": "{name} はすでに最新です", "geoUpdated": "{name} 更新済み", "secondsCount": "{count} 秒", - "entriesCount": "{count} エントリ" + "entriesCount": "{count} エントリ", + "features": "機能", + "hideAdvanced": "詳細設定を隠す", + "showAdvanced": "詳細設定を表示", + "tailscale": "Tailscale", + "tailscaleDesc": "Tailscale アウトバウンドノードを管理", + "tailscaleEnable": "Tailscale を有効化", + "tailscaleEnableDesc": "Tailscale ノードをアウトバウンドとして注入します。オフにすると Tailscale はトラフィックを処理しなくなりますが、通常のトラフィックには影響しません。", + "tailscaleEmptyTip": "Tailscale ノードがありません。追加すると、トラフィックを tailnet 経由で転送できます。", + "addTailscaleNode": "Tailscale ノードを追加", + "editTailscaleNode": "Tailscale ノードを編集", + "tailscaleAuthKey": "認証キー", + "tailscaleHostname": "ホスト名", + "tailscaleControlUrl": "コントロール URL", + "tailscaleStateDir": "状態ディレクトリ", + "tailscaleEphemeral": "エフェメラル", + "tailscaleUdp": "UDP リレー", + "tailscaleAcceptRoutes": "ルートを受け入れる", + "tailscaleExitNode": "出口ノード", + "tailscaleExitNodeAllowLanAccess": "出口ノード経由の LAN アクセスを許可", + "tailscaleNameExistsTip": "同じ名前のノードが既に存在します", + "tailscaleGuideTitle": "Tailscale の仕組み", + "tailscaleAuthKeyHint": "Tailscale 管理コンソール → 設定 → Keys から取得します。ノードの認証に必要です。", + "tailscaleHostnameHint": "任意。tailnet に表示されるデバイス名です。", + "tailscaleControlUrlHint": "任意。Headscale などの自己ホスト型コントロールサーバー用です。", + "tailscaleStateDirHint": "任意。Tailscale の状態を保存するディレクトリです。", + "tailscaleExitNodeHint": "任意。すべてのトラフィックを転送する tailnet 出口ノードの IP または名前です。", + "tailscaleBypass": "Tailscale のトラフィックを直結に保つ", + "tailscaleRoutes": "ルーティング先", + "tailscaleRoutesHint": "このノード経由で送るドメインまたは IP(1 行に 1 つ、例: 自宅 PC の Tailscale IP や MagicDNS 名)。", + "tailscaleScenarioAndroidTitle": "Android クライアント設定", + "tailscaleScenarioAndroidBody": "VPN は FlClash だけにしてください。Tailscale アプリの VPN と同時には使えません(Android は VPN を 1 つだけ許可)。下で内蔵 Tailscale ノードを追加し、自宅デバイスをルーティング先に入れてください。", + "tailscaleScenarioDesktopTitle": "デスクトップ / ホスト設定", + "tailscaleScenarioDesktopBody": "FlClash と正式な Tailscale アプリを同時に使えます。「Tailscale のトラフィックを直結に保つ」をオンにして、FlClash が制御プレーンや fake-IP DNS を横取りしないようにします。", + "tailscaleAndroidStep1": "Tailscale 管理コンソール(設定 → Keys)で認証キーを取得します。", + "tailscaleAndroidStep2": "ノードを追加し、認証キーを貼り付け、ルーティング先に自宅デバイスの IP または MagicDNS 名を入れます。", + "tailscaleAndroidStep3": "「Tailscale を有効化」をオンにします。Tailscale アプリも入れている場合以外は「直結に保つ」はオフのままで構いません。", + "tailscaleAndroidStep4": "FlClash VPN を開始し、ノード横のピンボタンで接続を確認します。", + "tailscaleDesktopStep1": "この PC で Tailscale アプリ/サービスも動かす場合は「Tailscale のトラフィックを直結に保つ」をオンにします。", + "tailscaleDesktopStep2": "任意: 認証キー付きの内蔵 Tailscale ノードを追加し、選択した通信を FlClash から tailnet 経由にします。", + "tailscaleDesktopStep3": "ルーティング先に宛先(自宅 IP / MagicDNS)を入れ、「Tailscale を有効化」をオンにします。", + "tailscaleDesktopStep4": "FlClash を開始し、ノード横のピンボタンで接続が安定しているか確認します。", + "tailscaleBypassRecommended": "Tailscale アプリ/サービスが入っているデスクトップでは推奨。DIRECT ルールと Fake IP Filter を自動管理します。", + "tailscaleBypassAndroidHint": "Android では通常オフのまま。この端末にも Tailscale アプリがある場合だけオンにしてください。", + "tailscaleStatusDisabled": "Tailscale はオフです — ノードは実行中の設定に注入されません。", + "tailscaleStatusNoNodes": "有効ですが、ノードがありません。まずノードを追加してください。", + "tailscaleStatusNeedStart": "ノードの準備ができました。FlClash VPN を開始してからピンでテストしてください。", + "tailscaleStatusReady": "{count} 個のノードが有効です。ノード横のピンで接続をテストできます。", + "tailscaleStatusNeedRoutes": "ノードは追加済みですがルート先がありません — ルートを追加するまで自動一致しません(または手動でノードを選択)。", + "tailscaleBypassNudge": "この端末では Tailscale も動いている可能性があります — 「Tailscale 通信を直通」をオンにして Fake IP / 制御面の不具合を避けてください。", + "tailscaleShowSetupGuide": "セットアップガイド", + "tailscaleEnableBypassAction": "有効にする", + "tailscaleNameHelper": "プロキシ一覧の出站名です。名前を変えると選択や遅延テストの対応も変わります。", + "tailscaleTestNeedEnable": "テストする前に「Tailscale を有効化」をオンにしてください。", + "tailscaleTestNeedStart": "接続をテストする前に FlClash VPN を開始してください。", + "tailscaleTestNode": "接続をテスト", + "tailscaleNotTested": "未テスト", + "tailscaleNoRoutes": "ルーティング先なし", + "tailscaleRoutesCount": "ルーティング先 {count} 件", + "tailscaleNodesTitle": "ノード", + "tailscaleTestTip": "ノード横のピンボタンで、Tailscale アウトバウンドが発信できるか確認できます。遅延が表示されれば接続は正常です。Timeout の場合は認証キー、「有効化」、FlClash VPN の起動を確認してください。" } diff --git a/arb/intl_ru.arb b/arb/intl_ru.arb index 4cdfdf3036..81d7f9fe4e 100644 --- a/arb/intl_ru.arb +++ b/arb/intl_ru.arb @@ -318,6 +318,8 @@ "messageTest": "Тестирование сообщения", "messageTestTip": "Это сообщение.", "crashTest": "Тест на сбои", + "crashDetected": "Обнаружен сбой", + "crashDetectedTip": "Во время предыдущего запуска произошёл сбой приложения. Чтобы предотвратить повторный сбой, текущий профиль был сброшен, а автоматическая настройка конфигурации пропущена.", "clearData": "Очистить данные", "textScale": "Масштабирование текста", "internet": "Интернет", @@ -547,8 +549,68 @@ "hoursCount": "{count} часов", "geoResources": "Ресурсы Geo", "geoUpdating": "Обновление {name}...", - "geoSkipped": "{name} пропущено", + "geoSkipped": "Для {name} уже установлена последняя версия", "geoUpdated": "{name} обновлено", "secondsCount": "{count} секунд", - "entriesCount": "{count} записей" + "entriesCount": "{count} записей", + "features": "Функции", + "hideAdvanced": "Скрыть дополнительно", + "showAdvanced": "Показать дополнительно", + "tailscale": "Tailscale", + "tailscaleDesc": "Управление исходящими узлами Tailscale", + "tailscaleEnable": "Включить Tailscale", + "tailscaleEnableDesc": "Добавлять узлы Tailscale как исходящие. При отключении Tailscale перестаёт обрабатывать трафик; обычный трафик не затрагивается.", + "tailscaleEmptyTip": "Узлов Tailscale пока нет. Добавьте узел, чтобы направлять трафик через вашу сеть tailnet.", + "addTailscaleNode": "Добавить узел Tailscale", + "editTailscaleNode": "Изменить узел Tailscale", + "tailscaleAuthKey": "Ключ аутентификации", + "tailscaleHostname": "Имя хоста", + "tailscaleControlUrl": "URL сервера управления", + "tailscaleStateDir": "Каталог состояния", + "tailscaleEphemeral": "Временный узел", + "tailscaleUdp": "Ретрансляция UDP", + "tailscaleAcceptRoutes": "Принимать маршруты", + "tailscaleExitNode": "Выходной узел", + "tailscaleExitNodeAllowLanAccess": "Разрешить доступ к локальной сети через выходной узел", + "tailscaleNameExistsTip": "Узел с таким именем уже существует", + "tailscaleGuideTitle": "Как работает Tailscale", + "tailscaleAuthKeyHint": "Из консоли администратора Tailscale → Settings → Keys. Требуется для аутентификации узла.", + "tailscaleHostnameHint": "Необязательно. Имя устройства, отображаемое в вашей сети tailnet.", + "tailscaleControlUrlHint": "Необязательно. Только для собственных серверов управления, например Headscale.", + "tailscaleStateDirHint": "Необязательно. Каталог для хранения состояния Tailscale.", + "tailscaleExitNodeHint": "Необязательно. IP-адрес или имя выходного узла tailnet для маршрутизации всего трафика.", + "tailscaleBypass": "Оставлять трафик Tailscale напрямую", + "tailscaleRoutes": "Пункты назначения маршрута", + "tailscaleRoutesHint": "Домены или IP, направляемые через этот узел, по одному в строке (например, Tailscale IP или имя MagicDNS вашего домашнего ПК).", + "tailscaleScenarioAndroidTitle": "Настройка клиента Android", + "tailscaleScenarioAndroidBody": "Оставьте FlClash единственным VPN. Не запускайте VPN приложения Tailscale одновременно (Android допускает только один). Добавьте встроенный узел Tailscale ниже и укажите домашнее устройство в пунктах назначения маршрута.", + "tailscaleScenarioDesktopTitle": "Настройка ПК / хоста", + "tailscaleScenarioDesktopBody": "Можно запускать FlClash и настоящее приложение Tailscale вместе. Включите «Оставлять трафик Tailscale напрямую», чтобы FlClash не перехватывал плоскость управления Tailscale и fake-IP DNS.", + "tailscaleAndroidStep1": "Получите ключ аутентификации в консоли администратора Tailscale (Settings → Keys).", + "tailscaleAndroidStep2": "Добавьте узел, вставьте ключ и укажите IP или имя MagicDNS домашнего устройства в пунктах назначения маршрута.", + "tailscaleAndroidStep3": "Включите Tailscale. «Оставлять трафик напрямую» обычно выключайте, если приложение Tailscale на телефоне не установлено.", + "tailscaleAndroidStep4": "Запустите VPN FlClash, затем нажмите кнопку ping у узла, чтобы проверить соединение.", + "tailscaleDesktopStep1": "Если на этом ПК также работает приложение/служба Tailscale, включите «Оставлять трафик Tailscale напрямую».", + "tailscaleDesktopStep2": "Необязательно: добавьте встроенный узел Tailscale с ключом, чтобы направлять выбранный трафик через tailnet из FlClash.", + "tailscaleDesktopStep3": "Укажите назначения (домашние IP / MagicDNS) в пунктах назначения маршрута и включите Tailscale.", + "tailscaleDesktopStep4": "Запустите FlClash и нажмите ping у узла, чтобы убедиться, что соединение стабильно.", + "tailscaleBypassRecommended": "Рекомендуется на ПК, где установлено приложение/служба Tailscale. Автоматически управляет правилами DIRECT и Fake IP Filter.", + "tailscaleBypassAndroidHint": "На Android обычно оставляйте выключенным. Включайте только если на телефоне также установлено приложение Tailscale.", + "tailscaleStatusDisabled": "Tailscale выключен — узлы не добавляются в рабочий профиль.", + "tailscaleStatusNoNodes": "Включено, но узлов ещё нет. Добавьте узел, чтобы начать.", + "tailscaleStatusNeedStart": "Узлы готовы. Запустите VPN FlClash, затем нажмите ping для проверки.", + "tailscaleStatusReady": "Активных узлов: {count}. Нажмите ping у узла, чтобы проверить связь.", + "tailscaleStatusNeedRoutes": "Узлы добавлены, но нет маршрутов — трафик не совпадёт, пока не добавите маршруты (или выберите узел вручную).", + "tailscaleBypassNudge": "На этом устройстве, возможно, уже запущен Tailscale — включите «Прямой трафик Tailscale», чтобы избежать проблем Fake IP / control plane.", + "tailscaleShowSetupGuide": "Инструкция", + "tailscaleEnableBypassAction": "Включить", + "tailscaleNameHelper": "Имя исходящего узла в Proxies. Переименование меняет привязку выбора и тестов задержки.", + "tailscaleTestNeedEnable": "Перед проверкой включите Tailscale.", + "tailscaleTestNeedStart": "Перед проверкой соединения запустите VPN FlClash.", + "tailscaleTestNode": "Проверить соединение", + "tailscaleNotTested": "Не проверено", + "tailscaleNoRoutes": "Нет пунктов назначения", + "tailscaleRoutesCount": "Маршрутов: {count}", + "tailscaleNodesTitle": "Узлы", + "tailscaleTestTip": "Кнопка ping рядом с узлом проверяет, может ли исходящий Tailscale установить соединение. Задержка означает, что связь есть; Timeout — проверьте ключ, переключатель включения и что VPN FlClash запущен." } diff --git a/arb/intl_zh_CN.arb b/arb/intl_zh_CN.arb index 28435c1095..6883c67345 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,8 +549,68 @@ "hoursCount": "{count} 小时", "geoResources": "Geo 资源", "geoUpdating": "正在更新 {name}...", - "geoSkipped": "{name} 已跳过", + "geoSkipped": "{name} 已是最新版本", "geoUpdated": "{name} 已更新", "secondsCount": "{count} 秒", - "entriesCount": "{count} 个条目" + "entriesCount": "{count} 个条目", + "features": "功能", + "hideAdvanced": "隐藏高级选项", + "showAdvanced": "显示高级选项", + "tailscale": "Tailscale", + "tailscaleDesc": "管理 Tailscale 出站节点", + "tailscaleEnable": "启用 Tailscale", + "tailscaleEnableDesc": "将 Tailscale 节点作为出站注入。关闭后 Tailscale 将不再处理流量,普通流量不受影响。", + "tailscaleEmptyTip": "暂无 Tailscale 节点。添加一个即可让流量经由你的 tailnet 转发。", + "addTailscaleNode": "添加 Tailscale 节点", + "editTailscaleNode": "编辑 Tailscale 节点", + "tailscaleAuthKey": "认证密钥", + "tailscaleHostname": "主机名", + "tailscaleControlUrl": "控制服务器地址", + "tailscaleStateDir": "状态目录", + "tailscaleEphemeral": "临时节点", + "tailscaleUdp": "UDP 转发", + "tailscaleAcceptRoutes": "接受路由", + "tailscaleExitNode": "出口节点", + "tailscaleExitNodeAllowLanAccess": "允许通过出口节点访问局域网", + "tailscaleNameExistsTip": "已存在同名节点", + "tailscaleGuideTitle": "Tailscale 使用说明", + "tailscaleAuthKeyHint": "来自 Tailscale 管理后台 → 设置 → Keys,用于对节点进行认证。", + "tailscaleHostnameHint": "可选。在 tailnet 中显示的设备名称。", + "tailscaleControlUrlHint": "可选。仅用于自建控制服务器(如 Headscale)。", + "tailscaleStateDirHint": "可选。用于持久化 Tailscale 状态的目录。", + "tailscaleExitNodeHint": "可选。tailnet 出口节点的 IP 或名称,用于转发全部流量。", + "tailscaleBypass": "保持 Tailscale 流量直连", + "tailscaleRoutes": "路由目标", + "tailscaleRoutesHint": "经由该节点转发的域名或 IP,每行一个(例如你家用电脑的 Tailscale IP 或 MagicDNS 名称)。", + "tailscaleScenarioAndroidTitle": "Android 客户端设置", + "tailscaleScenarioAndroidBody": "请只保留 FlClash 作为 VPN。不要同时开启 Tailscale 应用的 VPN(Android 同一时间只允许一个)。在下方添加内置 Tailscale 节点,并把家里的设备填入“路由目标”。", + "tailscaleScenarioDesktopTitle": "桌面 / 主机设置", + "tailscaleScenarioDesktopBody": "可以同时运行 FlClash 与正式的 Tailscale 应用。请开启“保持 Tailscale 流量直连”,避免 FlClash 劫持 Tailscale 控制面或 fake-IP DNS。", + "tailscaleAndroidStep1": "在 Tailscale 管理后台(设置 → Keys)获取认证密钥。", + "tailscaleAndroidStep2": "添加节点,粘贴认证密钥,并在“路由目标”中填入家里设备的 IP 或 MagicDNS 名称。", + "tailscaleAndroidStep3": "打开“启用 Tailscale”。除非本机也安装了 Tailscale 应用,否则请关闭“保持 Tailscale 流量直连”。", + "tailscaleAndroidStep4": "启动 FlClash VPN,然后点击节点旁的测速按钮检查连接。", + "tailscaleDesktopStep1": "如果这台电脑同时运行 Tailscale 应用/服务,请开启“保持 Tailscale 流量直连”。", + "tailscaleDesktopStep2": "可选:用认证密钥添加一个内置 Tailscale 节点,让 FlClash 将选定流量经由 tailnet 转发。", + "tailscaleDesktopStep3": "在“路由目标”中填写目标(家里的 IP / MagicDNS),然后打开“启用 Tailscale”。", + "tailscaleDesktopStep4": "启动 FlClash,然后点击节点旁的测速按钮确认连接是否正常。", + "tailscaleBypassRecommended": "在已安装 Tailscale 应用/服务的桌面上建议开启。会自动管理直连规则与 Fake IP Filter。", + "tailscaleBypassAndroidHint": "Android 上通常保持关闭。仅当本机也安装了 Tailscale 应用时再开启。", + "tailscaleStatusDisabled": "Tailscale 已关闭 — 节点不会注入到运行配置中。", + "tailscaleStatusNoNodes": "已启用,但还没有节点。请先添加一个节点。", + "tailscaleStatusNeedStart": "节点已就绪。请先启动 FlClash VPN,再点击测速。", + "tailscaleStatusReady": "已有 {count} 个节点。点击节点旁的测速按钮检查连通性。", + "tailscaleStatusNeedRoutes": "已添加节点,但还没有路由目标 — 在填写路由之前流量不会自动匹配(或请手动选择该节点)。", + "tailscaleBypassNudge": "本机可能已安装 Tailscale — 建议开启“Tailscale 流量直连”,避免 Fake IP / 控制面被劫持。", + "tailscaleShowSetupGuide": "设置指南", + "tailscaleEnableBypassAction": "开启", + "tailscaleNameHelper": "代理列表中的出站名称。重命名会改变选中项与延迟测试的对应关系。", + "tailscaleTestNeedEnable": "请先打开“启用 Tailscale”再测试。", + "tailscaleTestNeedStart": "请先启动 FlClash VPN 再测试连接。", + "tailscaleTestNode": "测试连接", + "tailscaleNotTested": "未测试", + "tailscaleNoRoutes": "无路由目标", + "tailscaleRoutesCount": "{count} 个路由目标", + "tailscaleNodesTitle": "节点", + "tailscaleTestTip": "点击节点旁的测速按钮,检查 Tailscale 出站是否能拨号。显示延迟表示连接正常;显示超时请检查认证密钥、“启用”开关,以及 FlClash VPN 是否已启动。" } 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/task.dart b/lib/common/task.dart index ea29017a88..3412413854 100644 --- a/lib/common/task.dart +++ b/lib/common/task.dart @@ -218,6 +218,25 @@ Future> _makeRealProfileTask( rawConfig['dns']['nameserver'] = [...nameserver, systemDns]; } } + + // Keep Tailscale control/DERP/MagicDNS out of Clash fake-IP so the real + // Tailscale daemon (or curl diagnostics) gets a public IP, not 198.18.x.x. + if (data.tailscaleFakeIpFilters.isNotEmpty) { + if (rawConfig['dns'] == null) { + rawConfig['dns'] = {}; + } + final existingFilters = [ + ...?((rawConfig['dns'] as Map)['fake-ip-filter'] as List?)?.map( + (item) => item.toString(), + ), + ]; + for (final filter in data.tailscaleFakeIpFilters) { + if (!existingFilters.contains(filter)) { + existingFilters.add(filter); + } + } + rawConfig['dns']['fake-ip-filter'] = existingFilters; + } List rules = []; if (data.rules.isEmpty) { if (rawConfig['rules'] != null) { @@ -266,8 +285,17 @@ Future> _makeRealProfileTask( if (data.proxyGroups.isNotEmpty) { rawConfig['proxy-groups'] = data.proxyGroups; } + + // Tailscale rules are injected at the very top so they take priority over the + // imported provider profile, without the user editing rules per profile. + if (data.tailscaleRules.isNotEmpty) { + rules = [...data.tailscaleRules, ...rules]; + } rawConfig['rules'] = rules; - final yaml = await _encodeYaml(Map.from(rawConfig)); + final mergedConfig = data.tailscaleProxies.mergeInto( + Map.from(rawConfig), + ); + final yaml = await _encodeYaml(Map.from(mergedConfig)); return VM2(yaml, yaml.toMd5()); } 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..06a4155787 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"; @@ -76,9 +76,14 @@ class MessageLookup extends MessageLookupByLibrary { static String m23(count) => "${count} items have been selected"; - static String m24(label) => "${label} must be a url"; + static String m24(count) => "${count} route(s)"; static String m25(count) => + "${count} node(s) active. Tap ping on a node to test connectivity."; + + static String m26(label) => "${label} must be a url"; + + static String m27(count) => "${Intl.plural(count, one: '1 year ago', other: '${count} years ago')}"; final messages = _notInlinedMessages(_notInlinedMessages); @@ -113,6 +118,9 @@ class MessageLookup extends MessageLookupByLibrary { ), "addRule": MessageLookupByLibrary.simpleMessage("Add rule"), "addSsid": MessageLookupByLibrary.simpleMessage("Add SSID"), + "addTailscaleNode": MessageLookupByLibrary.simpleMessage( + "Add Tailscale node", + ), "addedRules": MessageLookupByLibrary.simpleMessage("Added rules"), "additionalParameters": MessageLookupByLibrary.simpleMessage( "Additional parameters", @@ -272,6 +280,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( @@ -352,6 +364,9 @@ class MessageLookup extends MessageLookupByLibrary { "editProxyGroup": MessageLookupByLibrary.simpleMessage("Edit proxy group"), "editRule": MessageLookupByLibrary.simpleMessage("Edit rule"), "editSsid": MessageLookupByLibrary.simpleMessage("Edit SSID"), + "editTailscaleNode": MessageLookupByLibrary.simpleMessage( + "Edit Tailscale node", + ), "emptyTip": m4, "en": MessageLookupByLibrary.simpleMessage("English"), "entries": MessageLookupByLibrary.simpleMessage(" entries"), @@ -391,6 +406,7 @@ class MessageLookup extends MessageLookupByLibrary { "Generally use offshore DNS", ), "fallbackFilter": MessageLookupByLibrary.simpleMessage("Fallback filter"), + "features": MessageLookupByLibrary.simpleMessage("Features"), "fidelityScheme": MessageLookupByLibrary.simpleMessage("Fidelity"), "file": MessageLookupByLibrary.simpleMessage("File"), "fileDesc": MessageLookupByLibrary.simpleMessage("Directly upload profile"), @@ -435,6 +451,7 @@ class MessageLookup extends MessageLookupByLibrary { "hasCacheChange": MessageLookupByLibrary.simpleMessage( "Do you want to cache the changes?", ), + "hideAdvanced": MessageLookupByLibrary.simpleMessage("Hide advanced"), "hideFromList": MessageLookupByLibrary.simpleMessage("Hide from list"), "host": MessageLookupByLibrary.simpleMessage("Host"), "hostsDesc": MessageLookupByLibrary.simpleMessage("Add Hosts"), @@ -934,6 +951,7 @@ class MessageLookup extends MessageLookupByLibrary { "selectedCountTitle": m23, "settings": MessageLookupByLibrary.simpleMessage("Settings"), "show": MessageLookupByLibrary.simpleMessage("Show"), + "showAdvanced": MessageLookupByLibrary.simpleMessage("Show advanced"), "shrink": MessageLookupByLibrary.simpleMessage("Shrink"), "silentLaunch": MessageLookupByLibrary.simpleMessage("SilentLaunch"), "silentLaunchDesc": MessageLookupByLibrary.simpleMessage( @@ -985,6 +1003,147 @@ class MessageLookup extends MessageLookupByLibrary { "tabAnimationDesc": MessageLookupByLibrary.simpleMessage( "Effective only in mobile view", ), + "tailscale": MessageLookupByLibrary.simpleMessage("Tailscale"), + "tailscaleAcceptRoutes": MessageLookupByLibrary.simpleMessage( + "Accept routes", + ), + "tailscaleAndroidStep1": MessageLookupByLibrary.simpleMessage( + "Get an auth key from the Tailscale admin console (Settings → Keys).", + ), + "tailscaleAndroidStep2": MessageLookupByLibrary.simpleMessage( + "Add a node, paste the auth key, and put your home device IP or MagicDNS name in Route destinations.", + ), + "tailscaleAndroidStep3": MessageLookupByLibrary.simpleMessage( + "Turn on Enable Tailscale. Leave \"Keep Tailscale traffic direct\" off unless the Tailscale app is also installed.", + ), + "tailscaleAndroidStep4": MessageLookupByLibrary.simpleMessage( + "Start FlClash VPN, then tap the ping button on a node to verify the connection.", + ), + "tailscaleAuthKey": MessageLookupByLibrary.simpleMessage("Auth key"), + "tailscaleAuthKeyHint": MessageLookupByLibrary.simpleMessage( + "From the Tailscale admin console → Settings → Keys. Required to authenticate the node.", + ), + "tailscaleBypass": MessageLookupByLibrary.simpleMessage( + "Keep Tailscale traffic direct", + ), + "tailscaleBypassAndroidHint": MessageLookupByLibrary.simpleMessage( + "Usually leave this off on Android. Turn on only if the Tailscale app is also installed on this phone.", + ), + "tailscaleBypassNudge": MessageLookupByLibrary.simpleMessage( + "This device likely runs Tailscale already — turn on “Keep Tailscale traffic direct” to avoid fake-IP / control-plane breakage.", + ), + "tailscaleBypassRecommended": MessageLookupByLibrary.simpleMessage( + "Recommended on desktop when the Tailscale app/service is installed. Auto-manages DIRECT rules and Fake IP Filter.", + ), + "tailscaleControlUrl": MessageLookupByLibrary.simpleMessage("Control URL"), + "tailscaleControlUrlHint": MessageLookupByLibrary.simpleMessage( + "Optional. Only for self-hosted control servers such as Headscale.", + ), + "tailscaleDesc": MessageLookupByLibrary.simpleMessage( + "Manage Tailscale outbound nodes", + ), + "tailscaleDesktopStep1": MessageLookupByLibrary.simpleMessage( + "If this PC also runs the Tailscale app/service, turn on \"Keep Tailscale traffic direct\".", + ), + "tailscaleDesktopStep2": MessageLookupByLibrary.simpleMessage( + "Optional: add an embedded Tailscale node with an auth key to route selected traffic through the tailnet from FlClash.", + ), + "tailscaleDesktopStep3": MessageLookupByLibrary.simpleMessage( + "Put destinations (home IPs / MagicDNS) in Route destinations, then turn on Enable Tailscale.", + ), + "tailscaleDesktopStep4": MessageLookupByLibrary.simpleMessage( + "Start FlClash, then tap the ping button on a node to verify the connection is solid.", + ), + "tailscaleEmptyTip": MessageLookupByLibrary.simpleMessage( + "No Tailscale nodes yet. Add one to route traffic through your tailnet.", + ), + "tailscaleEnable": MessageLookupByLibrary.simpleMessage("Enable Tailscale"), + "tailscaleEnableBypassAction": MessageLookupByLibrary.simpleMessage( + "Enable", + ), + "tailscaleEnableDesc": MessageLookupByLibrary.simpleMessage( + "Inject Tailscale nodes as outbounds. Turning this off stops Tailscale from handling traffic; normal traffic is unaffected.", + ), + "tailscaleEphemeral": MessageLookupByLibrary.simpleMessage("Ephemeral"), + "tailscaleExitNode": MessageLookupByLibrary.simpleMessage("Exit node"), + "tailscaleExitNodeAllowLanAccess": MessageLookupByLibrary.simpleMessage( + "Allow LAN access via exit node", + ), + "tailscaleExitNodeHint": MessageLookupByLibrary.simpleMessage( + "Optional. IP or name of a tailnet exit node to route all traffic through.", + ), + "tailscaleGuideTitle": MessageLookupByLibrary.simpleMessage( + "How Tailscale works", + ), + "tailscaleHostname": MessageLookupByLibrary.simpleMessage("Hostname"), + "tailscaleHostnameHint": MessageLookupByLibrary.simpleMessage( + "Optional. Device name shown in your tailnet.", + ), + "tailscaleNameExistsTip": MessageLookupByLibrary.simpleMessage( + "A node with this name already exists", + ), + "tailscaleNameHelper": MessageLookupByLibrary.simpleMessage( + "Outbound name used in Proxies. Renaming changes how selections and delay tests key this node.", + ), + "tailscaleNoRoutes": MessageLookupByLibrary.simpleMessage( + "No route destinations", + ), + "tailscaleNodesTitle": MessageLookupByLibrary.simpleMessage("Nodes"), + "tailscaleNotTested": MessageLookupByLibrary.simpleMessage("Not tested"), + "tailscaleRoutes": MessageLookupByLibrary.simpleMessage( + "Route destinations", + ), + "tailscaleRoutesCount": m24, + "tailscaleRoutesHint": MessageLookupByLibrary.simpleMessage( + "Domains or IPs sent through this node, one per line (e.g. your home PC\'s Tailscale IP or MagicDNS name).", + ), + "tailscaleScenarioAndroidBody": MessageLookupByLibrary.simpleMessage( + "Keep FlClash as the only VPN. Do not run the Tailscale app VPN at the same time (Android allows only one). Use an embedded Tailscale node below, then add your home device to Route destinations.", + ), + "tailscaleScenarioAndroidTitle": MessageLookupByLibrary.simpleMessage( + "Android client setup", + ), + "tailscaleScenarioDesktopBody": MessageLookupByLibrary.simpleMessage( + "You can run FlClash and the real Tailscale app together. Turn on \"Keep Tailscale traffic direct\" so FlClash does not hijack Tailscale\'s control plane or fake-IP DNS.", + ), + "tailscaleScenarioDesktopTitle": MessageLookupByLibrary.simpleMessage( + "Desktop / host setup", + ), + "tailscaleShowSetupGuide": MessageLookupByLibrary.simpleMessage( + "Setup guide", + ), + "tailscaleStateDir": MessageLookupByLibrary.simpleMessage( + "State directory", + ), + "tailscaleStateDirHint": MessageLookupByLibrary.simpleMessage( + "Optional. Directory used to persist Tailscale state.", + ), + "tailscaleStatusDisabled": MessageLookupByLibrary.simpleMessage( + "Tailscale is off — nodes are not injected into the running profile.", + ), + "tailscaleStatusNeedRoutes": MessageLookupByLibrary.simpleMessage( + "Nodes are added, but no route destinations yet — traffic will not match until you add routes (or pick the node manually).", + ), + "tailscaleStatusNeedStart": MessageLookupByLibrary.simpleMessage( + "Nodes are ready. Start FlClash VPN, then tap ping to test.", + ), + "tailscaleStatusNoNodes": MessageLookupByLibrary.simpleMessage( + "Enabled, but no nodes yet. Add a node to get started.", + ), + "tailscaleStatusReady": m25, + "tailscaleTestNeedEnable": MessageLookupByLibrary.simpleMessage( + "Turn on Enable Tailscale before testing.", + ), + "tailscaleTestNeedStart": MessageLookupByLibrary.simpleMessage( + "Start FlClash VPN before testing the connection.", + ), + "tailscaleTestNode": MessageLookupByLibrary.simpleMessage( + "Test connection", + ), + "tailscaleTestTip": MessageLookupByLibrary.simpleMessage( + "Use the ping button next to a node to check whether the Tailscale outbound can dial out. A latency value means the connection is working; Timeout means check the auth key, Enable switch, and that FlClash VPN is started.", + ), + "tailscaleUdp": MessageLookupByLibrary.simpleMessage("UDP relay"), "tapToAuthorize": MessageLookupByLibrary.simpleMessage("Tap to authorize"), "tcpConcurrent": MessageLookupByLibrary.simpleMessage("TCP concurrent"), "tcpConcurrentDesc": MessageLookupByLibrary.simpleMessage( @@ -1031,7 +1190,7 @@ class MessageLookup extends MessageLookupByLibrary { "urlDesc": MessageLookupByLibrary.simpleMessage( "Obtain profile through URL", ), - "urlTip": m24, + "urlTip": m26, "useHosts": MessageLookupByLibrary.simpleMessage("Use hosts"), "useSystemHosts": MessageLookupByLibrary.simpleMessage("Use system hosts"), "userAgent": MessageLookupByLibrary.simpleMessage("User-Agent"), @@ -1051,7 +1210,7 @@ class MessageLookup extends MessageLookupByLibrary { "WebDAV configuration", ), "whitelistMode": MessageLookupByLibrary.simpleMessage("Whitelist mode"), - "yearsAgo": m25, + "yearsAgo": m27, "zh_CN": MessageLookupByLibrary.simpleMessage("Simplified Chinese"), }; } diff --git a/lib/l10n/intl/messages_ja.dart b/lib/l10n/intl/messages_ja.dart index ceac8a469b..cb55e2cbe9 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} 更新済み"; @@ -69,9 +69,13 @@ class MessageLookup extends MessageLookupByLibrary { static String m23(count) => "${count} 項目が選択されています"; - static String m24(label) => "${label}はURLである必要があります"; + static String m24(count) => "ルーティング先 ${count} 件"; - static String m25(count) => "${count}年前"; + static String m25(count) => "${count} 個のノードが有効です。ノード横のピンで接続をテストできます。"; + + static String m26(label) => "${label}はURLである必要があります"; + + static String m27(count) => "${count}年前"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -101,6 +105,9 @@ class MessageLookup extends MessageLookupByLibrary { "addProxyProviders": MessageLookupByLibrary.simpleMessage("プロキシプロバイダーを追加"), "addRule": MessageLookupByLibrary.simpleMessage("ルールを追加"), "addSsid": MessageLookupByLibrary.simpleMessage("SSIDを追加"), + "addTailscaleNode": MessageLookupByLibrary.simpleMessage( + "Tailscale ノードを追加", + ), "addedRules": MessageLookupByLibrary.simpleMessage("追加ルール"), "additionalParameters": MessageLookupByLibrary.simpleMessage("追加パラメータ"), "address": MessageLookupByLibrary.simpleMessage("アドレス"), @@ -212,6 +219,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( @@ -274,6 +285,9 @@ class MessageLookup extends MessageLookupByLibrary { "editProxyGroup": MessageLookupByLibrary.simpleMessage("プロキシグループを編集"), "editRule": MessageLookupByLibrary.simpleMessage("ルールを編集"), "editSsid": MessageLookupByLibrary.simpleMessage("SSIDを編集"), + "editTailscaleNode": MessageLookupByLibrary.simpleMessage( + "Tailscale ノードを編集", + ), "emptyTip": m4, "en": MessageLookupByLibrary.simpleMessage("英語"), "entries": MessageLookupByLibrary.simpleMessage(" エントリ"), @@ -307,6 +321,7 @@ class MessageLookup extends MessageLookupByLibrary { "fallback": MessageLookupByLibrary.simpleMessage("フォールバック"), "fallbackDesc": MessageLookupByLibrary.simpleMessage("通常はオフショアDNSを使用"), "fallbackFilter": MessageLookupByLibrary.simpleMessage("フォールバックフィルター"), + "features": MessageLookupByLibrary.simpleMessage("機能"), "fidelityScheme": MessageLookupByLibrary.simpleMessage("ハイファイデリティー"), "file": MessageLookupByLibrary.simpleMessage("ファイル"), "fileDesc": MessageLookupByLibrary.simpleMessage("プロファイルを直接アップロード"), @@ -343,6 +358,7 @@ class MessageLookup extends MessageLookupByLibrary { "goDownload": MessageLookupByLibrary.simpleMessage("ダウンロードへ"), "goToConfigureScript": MessageLookupByLibrary.simpleMessage("スクリプト設定に移動"), "hasCacheChange": MessageLookupByLibrary.simpleMessage("変更をキャッシュしますか?"), + "hideAdvanced": MessageLookupByLibrary.simpleMessage("詳細設定を隠す"), "hideFromList": MessageLookupByLibrary.simpleMessage("リストから隠す"), "host": MessageLookupByLibrary.simpleMessage("ホスト"), "hostsDesc": MessageLookupByLibrary.simpleMessage("ホストを追加"), @@ -756,6 +772,7 @@ class MessageLookup extends MessageLookupByLibrary { "selectedCountTitle": m23, "settings": MessageLookupByLibrary.simpleMessage("設定"), "show": MessageLookupByLibrary.simpleMessage("表示"), + "showAdvanced": MessageLookupByLibrary.simpleMessage("詳細設定を表示"), "shrink": MessageLookupByLibrary.simpleMessage("縮小"), "silentLaunch": MessageLookupByLibrary.simpleMessage("バックグラウンド起動"), "silentLaunchDesc": MessageLookupByLibrary.simpleMessage("バックグラウンドで起動"), @@ -799,6 +816,137 @@ class MessageLookup extends MessageLookupByLibrary { "tab": MessageLookupByLibrary.simpleMessage("タブ"), "tabAnimation": MessageLookupByLibrary.simpleMessage("タブアニメーション"), "tabAnimationDesc": MessageLookupByLibrary.simpleMessage("モバイル表示でのみ有効"), + "tailscale": MessageLookupByLibrary.simpleMessage("Tailscale"), + "tailscaleAcceptRoutes": MessageLookupByLibrary.simpleMessage("ルートを受け入れる"), + "tailscaleAndroidStep1": MessageLookupByLibrary.simpleMessage( + "Tailscale 管理コンソール(設定 → Keys)で認証キーを取得します。", + ), + "tailscaleAndroidStep2": MessageLookupByLibrary.simpleMessage( + "ノードを追加し、認証キーを貼り付け、ルーティング先に自宅デバイスの IP または MagicDNS 名を入れます。", + ), + "tailscaleAndroidStep3": MessageLookupByLibrary.simpleMessage( + "「Tailscale を有効化」をオンにします。Tailscale アプリも入れている場合以外は「直結に保つ」はオフのままで構いません。", + ), + "tailscaleAndroidStep4": MessageLookupByLibrary.simpleMessage( + "FlClash VPN を開始し、ノード横のピンボタンで接続を確認します。", + ), + "tailscaleAuthKey": MessageLookupByLibrary.simpleMessage("認証キー"), + "tailscaleAuthKeyHint": MessageLookupByLibrary.simpleMessage( + "Tailscale 管理コンソール → 設定 → Keys から取得します。ノードの認証に必要です。", + ), + "tailscaleBypass": MessageLookupByLibrary.simpleMessage( + "Tailscale のトラフィックを直結に保つ", + ), + "tailscaleBypassAndroidHint": MessageLookupByLibrary.simpleMessage( + "Android では通常オフのまま。この端末にも Tailscale アプリがある場合だけオンにしてください。", + ), + "tailscaleBypassNudge": MessageLookupByLibrary.simpleMessage( + "この端末では Tailscale も動いている可能性があります — 「Tailscale 通信を直通」をオンにして Fake IP / 制御面の不具合を避けてください。", + ), + "tailscaleBypassRecommended": MessageLookupByLibrary.simpleMessage( + "Tailscale アプリ/サービスが入っているデスクトップでは推奨。DIRECT ルールと Fake IP Filter を自動管理します。", + ), + "tailscaleControlUrl": MessageLookupByLibrary.simpleMessage("コントロール URL"), + "tailscaleControlUrlHint": MessageLookupByLibrary.simpleMessage( + "任意。Headscale などの自己ホスト型コントロールサーバー用です。", + ), + "tailscaleDesc": MessageLookupByLibrary.simpleMessage( + "Tailscale アウトバウンドノードを管理", + ), + "tailscaleDesktopStep1": MessageLookupByLibrary.simpleMessage( + "この PC で Tailscale アプリ/サービスも動かす場合は「Tailscale のトラフィックを直結に保つ」をオンにします。", + ), + "tailscaleDesktopStep2": MessageLookupByLibrary.simpleMessage( + "任意: 認証キー付きの内蔵 Tailscale ノードを追加し、選択した通信を FlClash から tailnet 経由にします。", + ), + "tailscaleDesktopStep3": MessageLookupByLibrary.simpleMessage( + "ルーティング先に宛先(自宅 IP / MagicDNS)を入れ、「Tailscale を有効化」をオンにします。", + ), + "tailscaleDesktopStep4": MessageLookupByLibrary.simpleMessage( + "FlClash を開始し、ノード横のピンボタンで接続が安定しているか確認します。", + ), + "tailscaleEmptyTip": MessageLookupByLibrary.simpleMessage( + "Tailscale ノードがありません。追加すると、トラフィックを tailnet 経由で転送できます。", + ), + "tailscaleEnable": MessageLookupByLibrary.simpleMessage("Tailscale を有効化"), + "tailscaleEnableBypassAction": MessageLookupByLibrary.simpleMessage( + "有効にする", + ), + "tailscaleEnableDesc": MessageLookupByLibrary.simpleMessage( + "Tailscale ノードをアウトバウンドとして注入します。オフにすると Tailscale はトラフィックを処理しなくなりますが、通常のトラフィックには影響しません。", + ), + "tailscaleEphemeral": MessageLookupByLibrary.simpleMessage("エフェメラル"), + "tailscaleExitNode": MessageLookupByLibrary.simpleMessage("出口ノード"), + "tailscaleExitNodeAllowLanAccess": MessageLookupByLibrary.simpleMessage( + "出口ノード経由の LAN アクセスを許可", + ), + "tailscaleExitNodeHint": MessageLookupByLibrary.simpleMessage( + "任意。すべてのトラフィックを転送する tailnet 出口ノードの IP または名前です。", + ), + "tailscaleGuideTitle": MessageLookupByLibrary.simpleMessage( + "Tailscale の仕組み", + ), + "tailscaleHostname": MessageLookupByLibrary.simpleMessage("ホスト名"), + "tailscaleHostnameHint": MessageLookupByLibrary.simpleMessage( + "任意。tailnet に表示されるデバイス名です。", + ), + "tailscaleNameExistsTip": MessageLookupByLibrary.simpleMessage( + "同じ名前のノードが既に存在します", + ), + "tailscaleNameHelper": MessageLookupByLibrary.simpleMessage( + "プロキシ一覧の出站名です。名前を変えると選択や遅延テストの対応も変わります。", + ), + "tailscaleNoRoutes": MessageLookupByLibrary.simpleMessage("ルーティング先なし"), + "tailscaleNodesTitle": MessageLookupByLibrary.simpleMessage("ノード"), + "tailscaleNotTested": MessageLookupByLibrary.simpleMessage("未テスト"), + "tailscaleRoutes": MessageLookupByLibrary.simpleMessage("ルーティング先"), + "tailscaleRoutesCount": m24, + "tailscaleRoutesHint": MessageLookupByLibrary.simpleMessage( + "このノード経由で送るドメインまたは IP(1 行に 1 つ、例: 自宅 PC の Tailscale IP や MagicDNS 名)。", + ), + "tailscaleScenarioAndroidBody": MessageLookupByLibrary.simpleMessage( + "VPN は FlClash だけにしてください。Tailscale アプリの VPN と同時には使えません(Android は VPN を 1 つだけ許可)。下で内蔵 Tailscale ノードを追加し、自宅デバイスをルーティング先に入れてください。", + ), + "tailscaleScenarioAndroidTitle": MessageLookupByLibrary.simpleMessage( + "Android クライアント設定", + ), + "tailscaleScenarioDesktopBody": MessageLookupByLibrary.simpleMessage( + "FlClash と正式な Tailscale アプリを同時に使えます。「Tailscale のトラフィックを直結に保つ」をオンにして、FlClash が制御プレーンや fake-IP DNS を横取りしないようにします。", + ), + "tailscaleScenarioDesktopTitle": MessageLookupByLibrary.simpleMessage( + "デスクトップ / ホスト設定", + ), + "tailscaleShowSetupGuide": MessageLookupByLibrary.simpleMessage( + "セットアップガイド", + ), + "tailscaleStateDir": MessageLookupByLibrary.simpleMessage("状態ディレクトリ"), + "tailscaleStateDirHint": MessageLookupByLibrary.simpleMessage( + "任意。Tailscale の状態を保存するディレクトリです。", + ), + "tailscaleStatusDisabled": MessageLookupByLibrary.simpleMessage( + "Tailscale はオフです — ノードは実行中の設定に注入されません。", + ), + "tailscaleStatusNeedRoutes": MessageLookupByLibrary.simpleMessage( + "ノードは追加済みですがルート先がありません — ルートを追加するまで自動一致しません(または手動でノードを選択)。", + ), + "tailscaleStatusNeedStart": MessageLookupByLibrary.simpleMessage( + "ノードの準備ができました。FlClash VPN を開始してからピンでテストしてください。", + ), + "tailscaleStatusNoNodes": MessageLookupByLibrary.simpleMessage( + "有効ですが、ノードがありません。まずノードを追加してください。", + ), + "tailscaleStatusReady": m25, + "tailscaleTestNeedEnable": MessageLookupByLibrary.simpleMessage( + "テストする前に「Tailscale を有効化」をオンにしてください。", + ), + "tailscaleTestNeedStart": MessageLookupByLibrary.simpleMessage( + "接続をテストする前に FlClash VPN を開始してください。", + ), + "tailscaleTestNode": MessageLookupByLibrary.simpleMessage("接続をテスト"), + "tailscaleTestTip": MessageLookupByLibrary.simpleMessage( + "ノード横のピンボタンで、Tailscale アウトバウンドが発信できるか確認できます。遅延が表示されれば接続は正常です。Timeout の場合は認証キー、「有効化」、FlClash VPN の起動を確認してください。", + ), + "tailscaleUdp": MessageLookupByLibrary.simpleMessage("UDP リレー"), "tapToAuthorize": MessageLookupByLibrary.simpleMessage("タップして許可"), "tcpConcurrent": MessageLookupByLibrary.simpleMessage("TCP並列処理"), "tcpConcurrentDesc": MessageLookupByLibrary.simpleMessage("TCP並列処理を許可"), @@ -835,7 +983,7 @@ class MessageLookup extends MessageLookupByLibrary { "upload": MessageLookupByLibrary.simpleMessage("アップロード"), "url": MessageLookupByLibrary.simpleMessage("URL"), "urlDesc": MessageLookupByLibrary.simpleMessage("URL経由でプロファイルを取得"), - "urlTip": m24, + "urlTip": m26, "useHosts": MessageLookupByLibrary.simpleMessage("ホストを使用"), "useSystemHosts": MessageLookupByLibrary.simpleMessage("システムホストを使用"), "userAgent": MessageLookupByLibrary.simpleMessage("ユーザーエージェント"), @@ -851,7 +999,7 @@ class MessageLookup extends MessageLookupByLibrary { "vpnTip": MessageLookupByLibrary.simpleMessage("変更はVPN再起動後に有効"), "webDAVConfiguration": MessageLookupByLibrary.simpleMessage("WebDAV設定"), "whitelistMode": MessageLookupByLibrary.simpleMessage("ホワイトリストモード"), - "yearsAgo": m25, + "yearsAgo": m27, "zh_CN": MessageLookupByLibrary.simpleMessage("簡体字中国語"), }; } diff --git a/lib/l10n/intl/messages_ru.dart b/lib/l10n/intl/messages_ru.dart index adf4d6de1e..4704144dce 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} обновлено"; @@ -75,9 +75,14 @@ class MessageLookup extends MessageLookupByLibrary { static String m23(count) => "Выбрано ${count} элементов"; - static String m24(label) => "${label} должен быть URL"; + static String m24(count) => "Маршрутов: ${count}"; static String m25(count) => + "Активных узлов: ${count}. Нажмите ping у узла, чтобы проверить связь."; + + static String m26(label) => "${label} должен быть URL"; + + static String m27(count) => "${Intl.plural(count, one: '${count} год назад', few: '${count} года назад', many: '${count} лет назад', other: '${count} года назад')}"; final messages = _notInlinedMessages(_notInlinedMessages); @@ -114,6 +119,9 @@ class MessageLookup extends MessageLookupByLibrary { ), "addRule": MessageLookupByLibrary.simpleMessage("Добавить правило"), "addSsid": MessageLookupByLibrary.simpleMessage("Добавить SSID"), + "addTailscaleNode": MessageLookupByLibrary.simpleMessage( + "Добавить узел Tailscale", + ), "addedRules": MessageLookupByLibrary.simpleMessage("Добавленные правила"), "additionalParameters": MessageLookupByLibrary.simpleMessage( "Дополнительные параметры", @@ -279,6 +287,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( @@ -361,6 +373,9 @@ class MessageLookup extends MessageLookupByLibrary { ), "editRule": MessageLookupByLibrary.simpleMessage("Редактировать правило"), "editSsid": MessageLookupByLibrary.simpleMessage("Изменить SSID"), + "editTailscaleNode": MessageLookupByLibrary.simpleMessage( + "Изменить узел Tailscale", + ), "emptyTip": m4, "en": MessageLookupByLibrary.simpleMessage("Английский"), "entries": MessageLookupByLibrary.simpleMessage(" записей"), @@ -404,6 +419,7 @@ class MessageLookup extends MessageLookupByLibrary { "fallbackFilter": MessageLookupByLibrary.simpleMessage( "Фильтр резервного DNS", ), + "features": MessageLookupByLibrary.simpleMessage("Функции"), "fidelityScheme": MessageLookupByLibrary.simpleMessage("Точная передача"), "file": MessageLookupByLibrary.simpleMessage("Файл"), "fileDesc": MessageLookupByLibrary.simpleMessage("Прямая загрузка профиля"), @@ -450,6 +466,9 @@ class MessageLookup extends MessageLookupByLibrary { "hasCacheChange": MessageLookupByLibrary.simpleMessage( "Хотите сохранить изменения в кэше?", ), + "hideAdvanced": MessageLookupByLibrary.simpleMessage( + "Скрыть дополнительно", + ), "hideFromList": MessageLookupByLibrary.simpleMessage("Скрыть из списка"), "host": MessageLookupByLibrary.simpleMessage("Хост"), "hostsDesc": MessageLookupByLibrary.simpleMessage("Добавить Hosts"), @@ -981,6 +1000,9 @@ class MessageLookup extends MessageLookupByLibrary { "selectedCountTitle": m23, "settings": MessageLookupByLibrary.simpleMessage("Настройки"), "show": MessageLookupByLibrary.simpleMessage("Показать"), + "showAdvanced": MessageLookupByLibrary.simpleMessage( + "Показать дополнительно", + ), "shrink": MessageLookupByLibrary.simpleMessage("Сжать"), "silentLaunch": MessageLookupByLibrary.simpleMessage("Тихий запуск"), "silentLaunchDesc": MessageLookupByLibrary.simpleMessage( @@ -1036,6 +1058,155 @@ class MessageLookup extends MessageLookupByLibrary { "tabAnimationDesc": MessageLookupByLibrary.simpleMessage( "Действительно только в мобильном виде", ), + "tailscale": MessageLookupByLibrary.simpleMessage("Tailscale"), + "tailscaleAcceptRoutes": MessageLookupByLibrary.simpleMessage( + "Принимать маршруты", + ), + "tailscaleAndroidStep1": MessageLookupByLibrary.simpleMessage( + "Получите ключ аутентификации в консоли администратора Tailscale (Settings → Keys).", + ), + "tailscaleAndroidStep2": MessageLookupByLibrary.simpleMessage( + "Добавьте узел, вставьте ключ и укажите IP или имя MagicDNS домашнего устройства в пунктах назначения маршрута.", + ), + "tailscaleAndroidStep3": MessageLookupByLibrary.simpleMessage( + "Включите Tailscale. «Оставлять трафик напрямую» обычно выключайте, если приложение Tailscale на телефоне не установлено.", + ), + "tailscaleAndroidStep4": MessageLookupByLibrary.simpleMessage( + "Запустите VPN FlClash, затем нажмите кнопку ping у узла, чтобы проверить соединение.", + ), + "tailscaleAuthKey": MessageLookupByLibrary.simpleMessage( + "Ключ аутентификации", + ), + "tailscaleAuthKeyHint": MessageLookupByLibrary.simpleMessage( + "Из консоли администратора Tailscale → Settings → Keys. Требуется для аутентификации узла.", + ), + "tailscaleBypass": MessageLookupByLibrary.simpleMessage( + "Оставлять трафик Tailscale напрямую", + ), + "tailscaleBypassAndroidHint": MessageLookupByLibrary.simpleMessage( + "На Android обычно оставляйте выключенным. Включайте только если на телефоне также установлено приложение Tailscale.", + ), + "tailscaleBypassNudge": MessageLookupByLibrary.simpleMessage( + "На этом устройстве, возможно, уже запущен Tailscale — включите «Прямой трафик Tailscale», чтобы избежать проблем Fake IP / control plane.", + ), + "tailscaleBypassRecommended": MessageLookupByLibrary.simpleMessage( + "Рекомендуется на ПК, где установлено приложение/служба Tailscale. Автоматически управляет правилами DIRECT и Fake IP Filter.", + ), + "tailscaleControlUrl": MessageLookupByLibrary.simpleMessage( + "URL сервера управления", + ), + "tailscaleControlUrlHint": MessageLookupByLibrary.simpleMessage( + "Необязательно. Только для собственных серверов управления, например Headscale.", + ), + "tailscaleDesc": MessageLookupByLibrary.simpleMessage( + "Управление исходящими узлами Tailscale", + ), + "tailscaleDesktopStep1": MessageLookupByLibrary.simpleMessage( + "Если на этом ПК также работает приложение/служба Tailscale, включите «Оставлять трафик Tailscale напрямую».", + ), + "tailscaleDesktopStep2": MessageLookupByLibrary.simpleMessage( + "Необязательно: добавьте встроенный узел Tailscale с ключом, чтобы направлять выбранный трафик через tailnet из FlClash.", + ), + "tailscaleDesktopStep3": MessageLookupByLibrary.simpleMessage( + "Укажите назначения (домашние IP / MagicDNS) в пунктах назначения маршрута и включите Tailscale.", + ), + "tailscaleDesktopStep4": MessageLookupByLibrary.simpleMessage( + "Запустите FlClash и нажмите ping у узла, чтобы убедиться, что соединение стабильно.", + ), + "tailscaleEmptyTip": MessageLookupByLibrary.simpleMessage( + "Узлов Tailscale пока нет. Добавьте узел, чтобы направлять трафик через вашу сеть tailnet.", + ), + "tailscaleEnable": MessageLookupByLibrary.simpleMessage( + "Включить Tailscale", + ), + "tailscaleEnableBypassAction": MessageLookupByLibrary.simpleMessage( + "Включить", + ), + "tailscaleEnableDesc": MessageLookupByLibrary.simpleMessage( + "Добавлять узлы Tailscale как исходящие. При отключении Tailscale перестаёт обрабатывать трафик; обычный трафик не затрагивается.", + ), + "tailscaleEphemeral": MessageLookupByLibrary.simpleMessage( + "Временный узел", + ), + "tailscaleExitNode": MessageLookupByLibrary.simpleMessage("Выходной узел"), + "tailscaleExitNodeAllowLanAccess": MessageLookupByLibrary.simpleMessage( + "Разрешить доступ к локальной сети через выходной узел", + ), + "tailscaleExitNodeHint": MessageLookupByLibrary.simpleMessage( + "Необязательно. IP-адрес или имя выходного узла tailnet для маршрутизации всего трафика.", + ), + "tailscaleGuideTitle": MessageLookupByLibrary.simpleMessage( + "Как работает Tailscale", + ), + "tailscaleHostname": MessageLookupByLibrary.simpleMessage("Имя хоста"), + "tailscaleHostnameHint": MessageLookupByLibrary.simpleMessage( + "Необязательно. Имя устройства, отображаемое в вашей сети tailnet.", + ), + "tailscaleNameExistsTip": MessageLookupByLibrary.simpleMessage( + "Узел с таким именем уже существует", + ), + "tailscaleNameHelper": MessageLookupByLibrary.simpleMessage( + "Имя исходящего узла в Proxies. Переименование меняет привязку выбора и тестов задержки.", + ), + "tailscaleNoRoutes": MessageLookupByLibrary.simpleMessage( + "Нет пунктов назначения", + ), + "tailscaleNodesTitle": MessageLookupByLibrary.simpleMessage("Узлы"), + "tailscaleNotTested": MessageLookupByLibrary.simpleMessage("Не проверено"), + "tailscaleRoutes": MessageLookupByLibrary.simpleMessage( + "Пункты назначения маршрута", + ), + "tailscaleRoutesCount": m24, + "tailscaleRoutesHint": MessageLookupByLibrary.simpleMessage( + "Домены или IP, направляемые через этот узел, по одному в строке (например, Tailscale IP или имя MagicDNS вашего домашнего ПК).", + ), + "tailscaleScenarioAndroidBody": MessageLookupByLibrary.simpleMessage( + "Оставьте FlClash единственным VPN. Не запускайте VPN приложения Tailscale одновременно (Android допускает только один). Добавьте встроенный узел Tailscale ниже и укажите домашнее устройство в пунктах назначения маршрута.", + ), + "tailscaleScenarioAndroidTitle": MessageLookupByLibrary.simpleMessage( + "Настройка клиента Android", + ), + "tailscaleScenarioDesktopBody": MessageLookupByLibrary.simpleMessage( + "Можно запускать FlClash и настоящее приложение Tailscale вместе. Включите «Оставлять трафик Tailscale напрямую», чтобы FlClash не перехватывал плоскость управления Tailscale и fake-IP DNS.", + ), + "tailscaleScenarioDesktopTitle": MessageLookupByLibrary.simpleMessage( + "Настройка ПК / хоста", + ), + "tailscaleShowSetupGuide": MessageLookupByLibrary.simpleMessage( + "Инструкция", + ), + "tailscaleStateDir": MessageLookupByLibrary.simpleMessage( + "Каталог состояния", + ), + "tailscaleStateDirHint": MessageLookupByLibrary.simpleMessage( + "Необязательно. Каталог для хранения состояния Tailscale.", + ), + "tailscaleStatusDisabled": MessageLookupByLibrary.simpleMessage( + "Tailscale выключен — узлы не добавляются в рабочий профиль.", + ), + "tailscaleStatusNeedRoutes": MessageLookupByLibrary.simpleMessage( + "Узлы добавлены, но нет маршрутов — трафик не совпадёт, пока не добавите маршруты (или выберите узел вручную).", + ), + "tailscaleStatusNeedStart": MessageLookupByLibrary.simpleMessage( + "Узлы готовы. Запустите VPN FlClash, затем нажмите ping для проверки.", + ), + "tailscaleStatusNoNodes": MessageLookupByLibrary.simpleMessage( + "Включено, но узлов ещё нет. Добавьте узел, чтобы начать.", + ), + "tailscaleStatusReady": m25, + "tailscaleTestNeedEnable": MessageLookupByLibrary.simpleMessage( + "Перед проверкой включите Tailscale.", + ), + "tailscaleTestNeedStart": MessageLookupByLibrary.simpleMessage( + "Перед проверкой соединения запустите VPN FlClash.", + ), + "tailscaleTestNode": MessageLookupByLibrary.simpleMessage( + "Проверить соединение", + ), + "tailscaleTestTip": MessageLookupByLibrary.simpleMessage( + "Кнопка ping рядом с узлом проверяет, может ли исходящий Tailscale установить соединение. Задержка означает, что связь есть; Timeout — проверьте ключ, переключатель включения и что VPN FlClash запущен.", + ), + "tailscaleUdp": MessageLookupByLibrary.simpleMessage("Ретрансляция UDP"), "tapToAuthorize": MessageLookupByLibrary.simpleMessage( "Нажмите, чтобы разрешить", ), @@ -1092,7 +1263,7 @@ class MessageLookup extends MessageLookupByLibrary { "urlDesc": MessageLookupByLibrary.simpleMessage( "Получить профиль через URL", ), - "urlTip": m24, + "urlTip": m26, "useHosts": MessageLookupByLibrary.simpleMessage("Использовать hosts"), "useSystemHosts": MessageLookupByLibrary.simpleMessage( "Использовать системные hosts", @@ -1116,7 +1287,7 @@ class MessageLookup extends MessageLookupByLibrary { "whitelistMode": MessageLookupByLibrary.simpleMessage( "Режим белого списка", ), - "yearsAgo": m25, + "yearsAgo": m27, "zh_CN": MessageLookupByLibrary.simpleMessage("Упрощенный китайский"), }; } diff --git a/lib/l10n/intl/messages_zh_CN.dart b/lib/l10n/intl/messages_zh_CN.dart index e198261265..8e3829b78e 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} 已更新"; @@ -69,9 +69,13 @@ class MessageLookup extends MessageLookupByLibrary { static String m23(count) => "已选择 ${count} 项"; - static String m24(label) => "${label}必须为URL"; + static String m24(count) => "${count} 个路由目标"; - static String m25(count) => "${count} 年前"; + static String m25(count) => "已有 ${count} 个节点。点击节点旁的测速按钮检查连通性。"; + + static String m26(label) => "${label}必须为URL"; + + static String m27(count) => "${count} 年前"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -99,6 +103,7 @@ class MessageLookup extends MessageLookupByLibrary { "addProxyProviders": MessageLookupByLibrary.simpleMessage("添加代理集"), "addRule": MessageLookupByLibrary.simpleMessage("添加规则"), "addSsid": MessageLookupByLibrary.simpleMessage("添加SSID"), + "addTailscaleNode": MessageLookupByLibrary.simpleMessage("添加 Tailscale 节点"), "addedRules": MessageLookupByLibrary.simpleMessage("附加规则"), "additionalParameters": MessageLookupByLibrary.simpleMessage("附加参数"), "address": MessageLookupByLibrary.simpleMessage("地址"), @@ -190,6 +195,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( @@ -246,6 +255,9 @@ class MessageLookup extends MessageLookupByLibrary { "editProxyGroup": MessageLookupByLibrary.simpleMessage("编辑策略组"), "editRule": MessageLookupByLibrary.simpleMessage("编辑规则"), "editSsid": MessageLookupByLibrary.simpleMessage("编辑SSID"), + "editTailscaleNode": MessageLookupByLibrary.simpleMessage( + "编辑 Tailscale 节点", + ), "emptyTip": m4, "en": MessageLookupByLibrary.simpleMessage("英语"), "entries": MessageLookupByLibrary.simpleMessage("个条目"), @@ -277,6 +289,7 @@ class MessageLookup extends MessageLookupByLibrary { "fallback": MessageLookupByLibrary.simpleMessage("Fallback"), "fallbackDesc": MessageLookupByLibrary.simpleMessage("一般情况下使用境外DNS"), "fallbackFilter": MessageLookupByLibrary.simpleMessage("Fallback过滤"), + "features": MessageLookupByLibrary.simpleMessage("功能"), "fidelityScheme": MessageLookupByLibrary.simpleMessage("高保真"), "file": MessageLookupByLibrary.simpleMessage("文件"), "fileDesc": MessageLookupByLibrary.simpleMessage("直接上传配置文件"), @@ -305,6 +318,7 @@ class MessageLookup extends MessageLookupByLibrary { "goDownload": MessageLookupByLibrary.simpleMessage("前往下载"), "goToConfigureScript": MessageLookupByLibrary.simpleMessage("前往配置脚本"), "hasCacheChange": MessageLookupByLibrary.simpleMessage("是否缓存修改"), + "hideAdvanced": MessageLookupByLibrary.simpleMessage("隐藏高级选项"), "hideFromList": MessageLookupByLibrary.simpleMessage("从列表中隐藏"), "host": MessageLookupByLibrary.simpleMessage("主机"), "hostsDesc": MessageLookupByLibrary.simpleMessage("追加Hosts"), @@ -650,6 +664,7 @@ class MessageLookup extends MessageLookupByLibrary { "selectedCountTitle": m23, "settings": MessageLookupByLibrary.simpleMessage("设置"), "show": MessageLookupByLibrary.simpleMessage("显示"), + "showAdvanced": MessageLookupByLibrary.simpleMessage("显示高级选项"), "shrink": MessageLookupByLibrary.simpleMessage("紧凑"), "silentLaunch": MessageLookupByLibrary.simpleMessage("静默启动"), "silentLaunchDesc": MessageLookupByLibrary.simpleMessage("后台启动"), @@ -689,6 +704,129 @@ class MessageLookup extends MessageLookupByLibrary { "tab": MessageLookupByLibrary.simpleMessage("标签页"), "tabAnimation": MessageLookupByLibrary.simpleMessage("选项卡动画"), "tabAnimationDesc": MessageLookupByLibrary.simpleMessage("仅在移动视图中有效"), + "tailscale": MessageLookupByLibrary.simpleMessage("Tailscale"), + "tailscaleAcceptRoutes": MessageLookupByLibrary.simpleMessage("接受路由"), + "tailscaleAndroidStep1": MessageLookupByLibrary.simpleMessage( + "在 Tailscale 管理后台(设置 → Keys)获取认证密钥。", + ), + "tailscaleAndroidStep2": MessageLookupByLibrary.simpleMessage( + "添加节点,粘贴认证密钥,并在“路由目标”中填入家里设备的 IP 或 MagicDNS 名称。", + ), + "tailscaleAndroidStep3": MessageLookupByLibrary.simpleMessage( + "打开“启用 Tailscale”。除非本机也安装了 Tailscale 应用,否则请关闭“保持 Tailscale 流量直连”。", + ), + "tailscaleAndroidStep4": MessageLookupByLibrary.simpleMessage( + "启动 FlClash VPN,然后点击节点旁的测速按钮检查连接。", + ), + "tailscaleAuthKey": MessageLookupByLibrary.simpleMessage("认证密钥"), + "tailscaleAuthKeyHint": MessageLookupByLibrary.simpleMessage( + "来自 Tailscale 管理后台 → 设置 → Keys,用于对节点进行认证。", + ), + "tailscaleBypass": MessageLookupByLibrary.simpleMessage( + "保持 Tailscale 流量直连", + ), + "tailscaleBypassAndroidHint": MessageLookupByLibrary.simpleMessage( + "Android 上通常保持关闭。仅当本机也安装了 Tailscale 应用时再开启。", + ), + "tailscaleBypassNudge": MessageLookupByLibrary.simpleMessage( + "本机可能已安装 Tailscale — 建议开启“Tailscale 流量直连”,避免 Fake IP / 控制面被劫持。", + ), + "tailscaleBypassRecommended": MessageLookupByLibrary.simpleMessage( + "在已安装 Tailscale 应用/服务的桌面上建议开启。会自动管理直连规则与 Fake IP Filter。", + ), + "tailscaleControlUrl": MessageLookupByLibrary.simpleMessage("控制服务器地址"), + "tailscaleControlUrlHint": MessageLookupByLibrary.simpleMessage( + "可选。仅用于自建控制服务器(如 Headscale)。", + ), + "tailscaleDesc": MessageLookupByLibrary.simpleMessage("管理 Tailscale 出站节点"), + "tailscaleDesktopStep1": MessageLookupByLibrary.simpleMessage( + "如果这台电脑同时运行 Tailscale 应用/服务,请开启“保持 Tailscale 流量直连”。", + ), + "tailscaleDesktopStep2": MessageLookupByLibrary.simpleMessage( + "可选:用认证密钥添加一个内置 Tailscale 节点,让 FlClash 将选定流量经由 tailnet 转发。", + ), + "tailscaleDesktopStep3": MessageLookupByLibrary.simpleMessage( + "在“路由目标”中填写目标(家里的 IP / MagicDNS),然后打开“启用 Tailscale”。", + ), + "tailscaleDesktopStep4": MessageLookupByLibrary.simpleMessage( + "启动 FlClash,然后点击节点旁的测速按钮确认连接是否正常。", + ), + "tailscaleEmptyTip": MessageLookupByLibrary.simpleMessage( + "暂无 Tailscale 节点。添加一个即可让流量经由你的 tailnet 转发。", + ), + "tailscaleEnable": MessageLookupByLibrary.simpleMessage("启用 Tailscale"), + "tailscaleEnableBypassAction": MessageLookupByLibrary.simpleMessage("开启"), + "tailscaleEnableDesc": MessageLookupByLibrary.simpleMessage( + "将 Tailscale 节点作为出站注入。关闭后 Tailscale 将不再处理流量,普通流量不受影响。", + ), + "tailscaleEphemeral": MessageLookupByLibrary.simpleMessage("临时节点"), + "tailscaleExitNode": MessageLookupByLibrary.simpleMessage("出口节点"), + "tailscaleExitNodeAllowLanAccess": MessageLookupByLibrary.simpleMessage( + "允许通过出口节点访问局域网", + ), + "tailscaleExitNodeHint": MessageLookupByLibrary.simpleMessage( + "可选。tailnet 出口节点的 IP 或名称,用于转发全部流量。", + ), + "tailscaleGuideTitle": MessageLookupByLibrary.simpleMessage( + "Tailscale 使用说明", + ), + "tailscaleHostname": MessageLookupByLibrary.simpleMessage("主机名"), + "tailscaleHostnameHint": MessageLookupByLibrary.simpleMessage( + "可选。在 tailnet 中显示的设备名称。", + ), + "tailscaleNameExistsTip": MessageLookupByLibrary.simpleMessage("已存在同名节点"), + "tailscaleNameHelper": MessageLookupByLibrary.simpleMessage( + "代理列表中的出站名称。重命名会改变选中项与延迟测试的对应关系。", + ), + "tailscaleNoRoutes": MessageLookupByLibrary.simpleMessage("无路由目标"), + "tailscaleNodesTitle": MessageLookupByLibrary.simpleMessage("节点"), + "tailscaleNotTested": MessageLookupByLibrary.simpleMessage("未测试"), + "tailscaleRoutes": MessageLookupByLibrary.simpleMessage("路由目标"), + "tailscaleRoutesCount": m24, + "tailscaleRoutesHint": MessageLookupByLibrary.simpleMessage( + "经由该节点转发的域名或 IP,每行一个(例如你家用电脑的 Tailscale IP 或 MagicDNS 名称)。", + ), + "tailscaleScenarioAndroidBody": MessageLookupByLibrary.simpleMessage( + "请只保留 FlClash 作为 VPN。不要同时开启 Tailscale 应用的 VPN(Android 同一时间只允许一个)。在下方添加内置 Tailscale 节点,并把家里的设备填入“路由目标”。", + ), + "tailscaleScenarioAndroidTitle": MessageLookupByLibrary.simpleMessage( + "Android 客户端设置", + ), + "tailscaleScenarioDesktopBody": MessageLookupByLibrary.simpleMessage( + "可以同时运行 FlClash 与正式的 Tailscale 应用。请开启“保持 Tailscale 流量直连”,避免 FlClash 劫持 Tailscale 控制面或 fake-IP DNS。", + ), + "tailscaleScenarioDesktopTitle": MessageLookupByLibrary.simpleMessage( + "桌面 / 主机设置", + ), + "tailscaleShowSetupGuide": MessageLookupByLibrary.simpleMessage("设置指南"), + "tailscaleStateDir": MessageLookupByLibrary.simpleMessage("状态目录"), + "tailscaleStateDirHint": MessageLookupByLibrary.simpleMessage( + "可选。用于持久化 Tailscale 状态的目录。", + ), + "tailscaleStatusDisabled": MessageLookupByLibrary.simpleMessage( + "Tailscale 已关闭 — 节点不会注入到运行配置中。", + ), + "tailscaleStatusNeedRoutes": MessageLookupByLibrary.simpleMessage( + "已添加节点,但还没有路由目标 — 在填写路由之前流量不会自动匹配(或请手动选择该节点)。", + ), + "tailscaleStatusNeedStart": MessageLookupByLibrary.simpleMessage( + "节点已就绪。请先启动 FlClash VPN,再点击测速。", + ), + "tailscaleStatusNoNodes": MessageLookupByLibrary.simpleMessage( + "已启用,但还没有节点。请先添加一个节点。", + ), + "tailscaleStatusReady": m25, + "tailscaleTestNeedEnable": MessageLookupByLibrary.simpleMessage( + "请先打开“启用 Tailscale”再测试。", + ), + "tailscaleTestNeedStart": MessageLookupByLibrary.simpleMessage( + "请先启动 FlClash VPN 再测试连接。", + ), + "tailscaleTestNode": MessageLookupByLibrary.simpleMessage("测试连接"), + "tailscaleTestTip": MessageLookupByLibrary.simpleMessage( + "点击节点旁的测速按钮,检查 Tailscale 出站是否能拨号。显示延迟表示连接正常;显示超时请检查认证密钥、“启用”开关,以及 FlClash VPN 是否已启动。", + ), + "tailscaleUdp": MessageLookupByLibrary.simpleMessage("UDP 转发"), "tapToAuthorize": MessageLookupByLibrary.simpleMessage("点击授权"), "tcpConcurrent": MessageLookupByLibrary.simpleMessage("TCP并发"), "tcpConcurrentDesc": MessageLookupByLibrary.simpleMessage("开启后允许TCP并发"), @@ -723,7 +861,7 @@ class MessageLookup extends MessageLookupByLibrary { "upload": MessageLookupByLibrary.simpleMessage("上传"), "url": MessageLookupByLibrary.simpleMessage("URL"), "urlDesc": MessageLookupByLibrary.simpleMessage("通过URL获取配置文件"), - "urlTip": m24, + "urlTip": m26, "useHosts": MessageLookupByLibrary.simpleMessage("使用Hosts"), "useSystemHosts": MessageLookupByLibrary.simpleMessage("使用系统Hosts"), "userAgent": MessageLookupByLibrary.simpleMessage("用户代理"), @@ -739,7 +877,7 @@ class MessageLookup extends MessageLookupByLibrary { "vpnTip": MessageLookupByLibrary.simpleMessage("重启VPN后改变生效"), "webDAVConfiguration": MessageLookupByLibrary.simpleMessage("WebDAV配置"), "whitelistMode": MessageLookupByLibrary.simpleMessage("白名单模式"), - "yearsAgo": m25, + "yearsAgo": m27, "zh_CN": MessageLookupByLibrary.simpleMessage("中文简体"), }; } diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 3b96b136b2..1b9f30df54 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], @@ -4493,6 +4513,591 @@ class AppLocalizations { args: [count], ); } + + /// `Features` + String get features { + return Intl.message('Features', name: 'features', desc: '', args: []); + } + + /// `Hide advanced` + String get hideAdvanced { + return Intl.message( + 'Hide advanced', + name: 'hideAdvanced', + desc: '', + args: [], + ); + } + + /// `Show advanced` + String get showAdvanced { + return Intl.message( + 'Show advanced', + name: 'showAdvanced', + desc: '', + args: [], + ); + } + + /// `Tailscale` + String get tailscale { + return Intl.message('Tailscale', name: 'tailscale', desc: '', args: []); + } + + /// `Manage Tailscale outbound nodes` + String get tailscaleDesc { + return Intl.message( + 'Manage Tailscale outbound nodes', + name: 'tailscaleDesc', + desc: '', + args: [], + ); + } + + /// `Enable Tailscale` + String get tailscaleEnable { + return Intl.message( + 'Enable Tailscale', + name: 'tailscaleEnable', + desc: '', + args: [], + ); + } + + /// `Inject Tailscale nodes as outbounds. Turning this off stops Tailscale from handling traffic; normal traffic is unaffected.` + String get tailscaleEnableDesc { + return Intl.message( + 'Inject Tailscale nodes as outbounds. Turning this off stops Tailscale from handling traffic; normal traffic is unaffected.', + name: 'tailscaleEnableDesc', + desc: '', + args: [], + ); + } + + /// `No Tailscale nodes yet. Add one to route traffic through your tailnet.` + String get tailscaleEmptyTip { + return Intl.message( + 'No Tailscale nodes yet. Add one to route traffic through your tailnet.', + name: 'tailscaleEmptyTip', + desc: '', + args: [], + ); + } + + /// `Add Tailscale node` + String get addTailscaleNode { + return Intl.message( + 'Add Tailscale node', + name: 'addTailscaleNode', + desc: '', + args: [], + ); + } + + /// `Edit Tailscale node` + String get editTailscaleNode { + return Intl.message( + 'Edit Tailscale node', + name: 'editTailscaleNode', + desc: '', + args: [], + ); + } + + /// `Auth key` + String get tailscaleAuthKey { + return Intl.message( + 'Auth key', + name: 'tailscaleAuthKey', + desc: '', + args: [], + ); + } + + /// `Hostname` + String get tailscaleHostname { + return Intl.message( + 'Hostname', + name: 'tailscaleHostname', + desc: '', + args: [], + ); + } + + /// `Control URL` + String get tailscaleControlUrl { + return Intl.message( + 'Control URL', + name: 'tailscaleControlUrl', + desc: '', + args: [], + ); + } + + /// `State directory` + String get tailscaleStateDir { + return Intl.message( + 'State directory', + name: 'tailscaleStateDir', + desc: '', + args: [], + ); + } + + /// `Ephemeral` + String get tailscaleEphemeral { + return Intl.message( + 'Ephemeral', + name: 'tailscaleEphemeral', + desc: '', + args: [], + ); + } + + /// `UDP relay` + String get tailscaleUdp { + return Intl.message('UDP relay', name: 'tailscaleUdp', desc: '', args: []); + } + + /// `Accept routes` + String get tailscaleAcceptRoutes { + return Intl.message( + 'Accept routes', + name: 'tailscaleAcceptRoutes', + desc: '', + args: [], + ); + } + + /// `Exit node` + String get tailscaleExitNode { + return Intl.message( + 'Exit node', + name: 'tailscaleExitNode', + desc: '', + args: [], + ); + } + + /// `Allow LAN access via exit node` + String get tailscaleExitNodeAllowLanAccess { + return Intl.message( + 'Allow LAN access via exit node', + name: 'tailscaleExitNodeAllowLanAccess', + desc: '', + args: [], + ); + } + + /// `A node with this name already exists` + String get tailscaleNameExistsTip { + return Intl.message( + 'A node with this name already exists', + name: 'tailscaleNameExistsTip', + desc: '', + args: [], + ); + } + + /// `How Tailscale works` + String get tailscaleGuideTitle { + return Intl.message( + 'How Tailscale works', + name: 'tailscaleGuideTitle', + desc: '', + args: [], + ); + } + + /// `From the Tailscale admin console → Settings → Keys. Required to authenticate the node.` + String get tailscaleAuthKeyHint { + return Intl.message( + 'From the Tailscale admin console → Settings → Keys. Required to authenticate the node.', + name: 'tailscaleAuthKeyHint', + desc: '', + args: [], + ); + } + + /// `Optional. Device name shown in your tailnet.` + String get tailscaleHostnameHint { + return Intl.message( + 'Optional. Device name shown in your tailnet.', + name: 'tailscaleHostnameHint', + desc: '', + args: [], + ); + } + + /// `Optional. Only for self-hosted control servers such as Headscale.` + String get tailscaleControlUrlHint { + return Intl.message( + 'Optional. Only for self-hosted control servers such as Headscale.', + name: 'tailscaleControlUrlHint', + desc: '', + args: [], + ); + } + + /// `Optional. Directory used to persist Tailscale state.` + String get tailscaleStateDirHint { + return Intl.message( + 'Optional. Directory used to persist Tailscale state.', + name: 'tailscaleStateDirHint', + desc: '', + args: [], + ); + } + + /// `Optional. IP or name of a tailnet exit node to route all traffic through.` + String get tailscaleExitNodeHint { + return Intl.message( + 'Optional. IP or name of a tailnet exit node to route all traffic through.', + name: 'tailscaleExitNodeHint', + desc: '', + args: [], + ); + } + + /// `Keep Tailscale traffic direct` + String get tailscaleBypass { + return Intl.message( + 'Keep Tailscale traffic direct', + name: 'tailscaleBypass', + desc: '', + args: [], + ); + } + + /// `Route destinations` + String get tailscaleRoutes { + return Intl.message( + 'Route destinations', + name: 'tailscaleRoutes', + desc: '', + args: [], + ); + } + + /// `Domains or IPs sent through this node, one per line (e.g. your home PC's Tailscale IP or MagicDNS name).` + String get tailscaleRoutesHint { + return Intl.message( + 'Domains or IPs sent through this node, one per line (e.g. your home PC\'s Tailscale IP or MagicDNS name).', + name: 'tailscaleRoutesHint', + desc: '', + args: [], + ); + } + + /// `Android client setup` + String get tailscaleScenarioAndroidTitle { + return Intl.message( + 'Android client setup', + name: 'tailscaleScenarioAndroidTitle', + desc: '', + args: [], + ); + } + + /// `Keep FlClash as the only VPN. Do not run the Tailscale app VPN at the same time (Android allows only one). Use an embedded Tailscale node below, then add your home device to Route destinations.` + String get tailscaleScenarioAndroidBody { + return Intl.message( + 'Keep FlClash as the only VPN. Do not run the Tailscale app VPN at the same time (Android allows only one). Use an embedded Tailscale node below, then add your home device to Route destinations.', + name: 'tailscaleScenarioAndroidBody', + desc: '', + args: [], + ); + } + + /// `Desktop / host setup` + String get tailscaleScenarioDesktopTitle { + return Intl.message( + 'Desktop / host setup', + name: 'tailscaleScenarioDesktopTitle', + desc: '', + args: [], + ); + } + + /// `You can run FlClash and the real Tailscale app together. Turn on "Keep Tailscale traffic direct" so FlClash does not hijack Tailscale's control plane or fake-IP DNS.` + String get tailscaleScenarioDesktopBody { + return Intl.message( + 'You can run FlClash and the real Tailscale app together. Turn on "Keep Tailscale traffic direct" so FlClash does not hijack Tailscale\'s control plane or fake-IP DNS.', + name: 'tailscaleScenarioDesktopBody', + desc: '', + args: [], + ); + } + + /// `Get an auth key from the Tailscale admin console (Settings → Keys).` + String get tailscaleAndroidStep1 { + return Intl.message( + 'Get an auth key from the Tailscale admin console (Settings → Keys).', + name: 'tailscaleAndroidStep1', + desc: '', + args: [], + ); + } + + /// `Add a node, paste the auth key, and put your home device IP or MagicDNS name in Route destinations.` + String get tailscaleAndroidStep2 { + return Intl.message( + 'Add a node, paste the auth key, and put your home device IP or MagicDNS name in Route destinations.', + name: 'tailscaleAndroidStep2', + desc: '', + args: [], + ); + } + + /// `Turn on Enable Tailscale. Leave "Keep Tailscale traffic direct" off unless the Tailscale app is also installed.` + String get tailscaleAndroidStep3 { + return Intl.message( + 'Turn on Enable Tailscale. Leave "Keep Tailscale traffic direct" off unless the Tailscale app is also installed.', + name: 'tailscaleAndroidStep3', + desc: '', + args: [], + ); + } + + /// `Start FlClash VPN, then tap the ping button on a node to verify the connection.` + String get tailscaleAndroidStep4 { + return Intl.message( + 'Start FlClash VPN, then tap the ping button on a node to verify the connection.', + name: 'tailscaleAndroidStep4', + desc: '', + args: [], + ); + } + + /// `If this PC also runs the Tailscale app/service, turn on "Keep Tailscale traffic direct".` + String get tailscaleDesktopStep1 { + return Intl.message( + 'If this PC also runs the Tailscale app/service, turn on "Keep Tailscale traffic direct".', + name: 'tailscaleDesktopStep1', + desc: '', + args: [], + ); + } + + /// `Optional: add an embedded Tailscale node with an auth key to route selected traffic through the tailnet from FlClash.` + String get tailscaleDesktopStep2 { + return Intl.message( + 'Optional: add an embedded Tailscale node with an auth key to route selected traffic through the tailnet from FlClash.', + name: 'tailscaleDesktopStep2', + desc: '', + args: [], + ); + } + + /// `Put destinations (home IPs / MagicDNS) in Route destinations, then turn on Enable Tailscale.` + String get tailscaleDesktopStep3 { + return Intl.message( + 'Put destinations (home IPs / MagicDNS) in Route destinations, then turn on Enable Tailscale.', + name: 'tailscaleDesktopStep3', + desc: '', + args: [], + ); + } + + /// `Start FlClash, then tap the ping button on a node to verify the connection is solid.` + String get tailscaleDesktopStep4 { + return Intl.message( + 'Start FlClash, then tap the ping button on a node to verify the connection is solid.', + name: 'tailscaleDesktopStep4', + desc: '', + args: [], + ); + } + + /// `Recommended on desktop when the Tailscale app/service is installed. Auto-manages DIRECT rules and Fake IP Filter.` + String get tailscaleBypassRecommended { + return Intl.message( + 'Recommended on desktop when the Tailscale app/service is installed. Auto-manages DIRECT rules and Fake IP Filter.', + name: 'tailscaleBypassRecommended', + desc: '', + args: [], + ); + } + + /// `Usually leave this off on Android. Turn on only if the Tailscale app is also installed on this phone.` + String get tailscaleBypassAndroidHint { + return Intl.message( + 'Usually leave this off on Android. Turn on only if the Tailscale app is also installed on this phone.', + name: 'tailscaleBypassAndroidHint', + desc: '', + args: [], + ); + } + + /// `Tailscale is off — nodes are not injected into the running profile.` + String get tailscaleStatusDisabled { + return Intl.message( + 'Tailscale is off — nodes are not injected into the running profile.', + name: 'tailscaleStatusDisabled', + desc: '', + args: [], + ); + } + + /// `Enabled, but no nodes yet. Add a node to get started.` + String get tailscaleStatusNoNodes { + return Intl.message( + 'Enabled, but no nodes yet. Add a node to get started.', + name: 'tailscaleStatusNoNodes', + desc: '', + args: [], + ); + } + + /// `Nodes are ready. Start FlClash VPN, then tap ping to test.` + String get tailscaleStatusNeedStart { + return Intl.message( + 'Nodes are ready. Start FlClash VPN, then tap ping to test.', + name: 'tailscaleStatusNeedStart', + desc: '', + args: [], + ); + } + + /// `{count} node(s) active. Tap ping on a node to test connectivity.` + String tailscaleStatusReady(Object count) { + return Intl.message( + '$count node(s) active. Tap ping on a node to test connectivity.', + name: 'tailscaleStatusReady', + desc: '', + args: [count], + ); + } + + /// `Nodes are added, but no route destinations yet — traffic will not match until you add routes (or pick the node manually).` + String get tailscaleStatusNeedRoutes { + return Intl.message( + 'Nodes are added, but no route destinations yet — traffic will not match until you add routes (or pick the node manually).', + name: 'tailscaleStatusNeedRoutes', + desc: '', + args: [], + ); + } + + /// `This device likely runs Tailscale already — turn on “Keep Tailscale traffic direct” to avoid fake-IP / control-plane breakage.` + String get tailscaleBypassNudge { + return Intl.message( + 'This device likely runs Tailscale already — turn on “Keep Tailscale traffic direct” to avoid fake-IP / control-plane breakage.', + name: 'tailscaleBypassNudge', + desc: '', + args: [], + ); + } + + /// `Setup guide` + String get tailscaleShowSetupGuide { + return Intl.message( + 'Setup guide', + name: 'tailscaleShowSetupGuide', + desc: '', + args: [], + ); + } + + /// `Enable` + String get tailscaleEnableBypassAction { + return Intl.message( + 'Enable', + name: 'tailscaleEnableBypassAction', + desc: '', + args: [], + ); + } + + /// `Outbound name used in Proxies. Renaming changes how selections and delay tests key this node.` + String get tailscaleNameHelper { + return Intl.message( + 'Outbound name used in Proxies. Renaming changes how selections and delay tests key this node.', + name: 'tailscaleNameHelper', + desc: '', + args: [], + ); + } + + /// `Turn on Enable Tailscale before testing.` + String get tailscaleTestNeedEnable { + return Intl.message( + 'Turn on Enable Tailscale before testing.', + name: 'tailscaleTestNeedEnable', + desc: '', + args: [], + ); + } + + /// `Start FlClash VPN before testing the connection.` + String get tailscaleTestNeedStart { + return Intl.message( + 'Start FlClash VPN before testing the connection.', + name: 'tailscaleTestNeedStart', + desc: '', + args: [], + ); + } + + /// `Test connection` + String get tailscaleTestNode { + return Intl.message( + 'Test connection', + name: 'tailscaleTestNode', + desc: '', + args: [], + ); + } + + /// `Not tested` + String get tailscaleNotTested { + return Intl.message( + 'Not tested', + name: 'tailscaleNotTested', + desc: '', + args: [], + ); + } + + /// `No route destinations` + String get tailscaleNoRoutes { + return Intl.message( + 'No route destinations', + name: 'tailscaleNoRoutes', + desc: '', + args: [], + ); + } + + /// `{count} route(s)` + String tailscaleRoutesCount(Object count) { + return Intl.message( + '$count route(s)', + name: 'tailscaleRoutesCount', + desc: '', + args: [count], + ); + } + + /// `Nodes` + String get tailscaleNodesTitle { + return Intl.message( + 'Nodes', + name: 'tailscaleNodesTitle', + desc: '', + args: [], + ); + } + + /// `Use the ping button next to a node to check whether the Tailscale outbound can dial out. A latency value means the connection is working; Timeout means check the auth key, Enable switch, and that FlClash VPN is started.` + String get tailscaleTestTip { + return Intl.message( + 'Use the ping button next to a node to check whether the Tailscale outbound can dial out. A latency value means the connection is working; Timeout means check the auth key, Enable switch, and that FlClash VPN is started.', + name: 'tailscaleTestTip', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { 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..093824af06 100644 --- a/lib/manager/app_manager.dart +++ b/lib/manager/app_manager.dart @@ -6,6 +6,7 @@ import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/manager/window_manager.dart'; import 'package:fl_clash/providers/providers.dart'; import 'package:fl_clash/state.dart'; +import 'package:fl_clash/widgets/animated_visibility.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -87,9 +88,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 +126,7 @@ class AppEnvManager extends StatelessWidget { } if (globalState.isPre) { return Banner( - message: 'PRE', + message: globalState.appEnv.toUpperCase(), location: BannerLocation.topEnd, child: child, ); @@ -193,98 +191,105 @@ class AppSidebarContainer extends ConsumerWidget { final navigationState = ref.watch(navigationStateProvider); final navigationItems = navigationState.navigationItems; final isMobileView = navigationState.viewMode == ViewMode.mobile; - if (isMobileView) { - return child; - } final currentIndex = navigationState.currentIndex; final showLabel = ref.watch(appSettingProvider).showLabel; - return Row( - children: [ - _buildBackground( - context: context, - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (system.isMacOS) const SizedBox(height: 22), - const SizedBox(height: 10), - if (!system.isMacOS) ...[ - const ClipRect(child: AppIcon()), - const SizedBox(height: 12), - ], - Expanded( - child: ScrollConfiguration( - behavior: HiddenBarScrollBehavior(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: NavigationRail( - scrollable: true, - minExtendedWidth: 200, - backgroundColor: Colors.transparent, - selectedLabelTextStyle: context - .textTheme - .labelLarge! - .copyWith(color: context.colorScheme.onSurface), - unselectedLabelTextStyle: context - .textTheme - .labelLarge! - .copyWith(color: context.colorScheme.onSurface), - destinations: navigationItems - .map( - (e) => NavigationRailDestination( - icon: e.icon, - label: Text(Intl.message(e.label.name)), - ), - ) - .toList(), - onDestinationSelected: (index) { - _handleToPage(navigationItems[index].label); - }, - extended: false, - selectedIndex: currentIndex, - labelType: showLabel - ? NavigationRailLabelType.all - : NavigationRailLabelType.none, - ), + return Container( + color: context.colorScheme.surfaceContainer, + child: Row( + children: [ + AnimatedVisibility.sidebar( + visible: !isMobileView, + child: _buildBackground( + context: context, + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (system.isMacOS) const SizedBox(height: 22), + const SizedBox(height: 10), + if (!system.isMacOS) ...[ + const ClipRect(child: AppIcon()), + const SizedBox(height: 12), + ], + Expanded( + child: ScrollConfiguration( + behavior: HiddenBarScrollBehavior(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: NavigationRail( + scrollable: true, + minExtendedWidth: 200, + backgroundColor: Colors.transparent, + selectedLabelTextStyle: context + .textTheme + .labelLarge! + .copyWith( + color: context.colorScheme.onSurface, + ), + unselectedLabelTextStyle: context + .textTheme + .labelLarge! + .copyWith( + color: context.colorScheme.onSurface, + ), + destinations: navigationItems + .map( + (e) => NavigationRailDestination( + icon: e.icon, + label: Text(Intl.message(e.label.name)), + ), + ) + .toList(), + onDestinationSelected: (index) { + _handleToPage(navigationItems[index].label); + }, + extended: false, + selectedIndex: currentIndex, + labelType: showLabel + ? NavigationRailLabelType.all + : NavigationRailLabelType.none, + ), + ), + ], ), - ], + ), ), - ), - ), - const SizedBox(height: 16), - IconButton( - onPressed: () { - ref - .read(appSettingProvider.notifier) - .update( - (state) => - state.copyWith(showLabel: !state.showLabel), - ); - }, - icon: Icon( - Icons.menu, - color: context.colorScheme.onSurfaceVariant, - ), + const SizedBox(height: 16), + IconButton( + onPressed: () { + ref + .read(appSettingProvider.notifier) + .update( + (state) => + state.copyWith(showLabel: !state.showLabel), + ); + }, + icon: Icon( + Icons.menu, + color: context.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + ], ), - const SizedBox(height: 16), - ], + ), ), ), - ), - Expanded( - flex: 1, - child: ClipRect( - child: LayoutBuilder( - builder: (_, constraints) { - _updateSideBarWidth(ref, constraints.maxWidth); - return child; - }, + Expanded( + flex: 1, + child: ClipRect( + child: LayoutBuilder( + builder: (_, constraints) { + _updateSideBarWidth(ref, constraints.maxWidth); + return 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/config.dart b/lib/models/config.dart index a71ee4e6bf..10b643e6af 100644 --- a/lib/models/config.dart +++ b/lib/models/config.dart @@ -246,6 +246,9 @@ abstract class Config with _$Config { @Default(defaultWindowProps) WindowProps windowProps, @Default(defaultClashConfig) PatchClashConfig patchClashConfig, @Default([]) List excludeSSIDs, + @JsonKey(fromJson: TailscaleProps.safeFromJson) + @Default(defaultTailscaleProps) + TailscaleProps tailscaleProps, }) = _Config; factory Config.fromJson(Map json) => _$ConfigFromJson(json); 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/config.freezed.dart b/lib/models/generated/config.freezed.dart index e776a21159..18587c4e5a 100644 --- a/lib/models/generated/config.freezed.dart +++ b/lib/models/generated/config.freezed.dart @@ -2329,7 +2329,7 @@ $TextScaleCopyWith<$Res> get textScale { /// @nodoc mixin _$Config { - int? get currentProfileId; bool get overrideDns; List get hotKeyActions;@JsonKey(fromJson: AppSettingProps.safeFromJson) AppSettingProps get appSettingProps; DAVProps? get davProps; NetworkProps get networkProps; VpnProps get vpnProps;@JsonKey(fromJson: ThemeProps.safeFromJson) ThemeProps get themeProps; ProxiesStyleProps get proxiesStyleProps; WindowProps get windowProps; PatchClashConfig get patchClashConfig; List get excludeSSIDs; + int? get currentProfileId; bool get overrideDns; List get hotKeyActions;@JsonKey(fromJson: AppSettingProps.safeFromJson) AppSettingProps get appSettingProps; DAVProps? get davProps; NetworkProps get networkProps; VpnProps get vpnProps;@JsonKey(fromJson: ThemeProps.safeFromJson) ThemeProps get themeProps; ProxiesStyleProps get proxiesStyleProps; WindowProps get windowProps; PatchClashConfig get patchClashConfig; List get excludeSSIDs;@JsonKey(fromJson: TailscaleProps.safeFromJson) TailscaleProps get tailscaleProps; /// Create a copy of Config /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -2342,16 +2342,16 @@ $ConfigCopyWith get copyWith => _$ConfigCopyWithImpl(this as Con @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is Config&&(identical(other.currentProfileId, currentProfileId) || other.currentProfileId == currentProfileId)&&(identical(other.overrideDns, overrideDns) || other.overrideDns == overrideDns)&&const DeepCollectionEquality().equals(other.hotKeyActions, hotKeyActions)&&(identical(other.appSettingProps, appSettingProps) || other.appSettingProps == appSettingProps)&&(identical(other.davProps, davProps) || other.davProps == davProps)&&(identical(other.networkProps, networkProps) || other.networkProps == networkProps)&&(identical(other.vpnProps, vpnProps) || other.vpnProps == vpnProps)&&(identical(other.themeProps, themeProps) || other.themeProps == themeProps)&&(identical(other.proxiesStyleProps, proxiesStyleProps) || other.proxiesStyleProps == proxiesStyleProps)&&(identical(other.windowProps, windowProps) || other.windowProps == windowProps)&&(identical(other.patchClashConfig, patchClashConfig) || other.patchClashConfig == patchClashConfig)&&const DeepCollectionEquality().equals(other.excludeSSIDs, excludeSSIDs)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is Config&&(identical(other.currentProfileId, currentProfileId) || other.currentProfileId == currentProfileId)&&(identical(other.overrideDns, overrideDns) || other.overrideDns == overrideDns)&&const DeepCollectionEquality().equals(other.hotKeyActions, hotKeyActions)&&(identical(other.appSettingProps, appSettingProps) || other.appSettingProps == appSettingProps)&&(identical(other.davProps, davProps) || other.davProps == davProps)&&(identical(other.networkProps, networkProps) || other.networkProps == networkProps)&&(identical(other.vpnProps, vpnProps) || other.vpnProps == vpnProps)&&(identical(other.themeProps, themeProps) || other.themeProps == themeProps)&&(identical(other.proxiesStyleProps, proxiesStyleProps) || other.proxiesStyleProps == proxiesStyleProps)&&(identical(other.windowProps, windowProps) || other.windowProps == windowProps)&&(identical(other.patchClashConfig, patchClashConfig) || other.patchClashConfig == patchClashConfig)&&const DeepCollectionEquality().equals(other.excludeSSIDs, excludeSSIDs)&&(identical(other.tailscaleProps, tailscaleProps) || other.tailscaleProps == tailscaleProps)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,currentProfileId,overrideDns,const DeepCollectionEquality().hash(hotKeyActions),appSettingProps,davProps,networkProps,vpnProps,themeProps,proxiesStyleProps,windowProps,patchClashConfig,const DeepCollectionEquality().hash(excludeSSIDs)); +int get hashCode => Object.hash(runtimeType,currentProfileId,overrideDns,const DeepCollectionEquality().hash(hotKeyActions),appSettingProps,davProps,networkProps,vpnProps,themeProps,proxiesStyleProps,windowProps,patchClashConfig,const DeepCollectionEquality().hash(excludeSSIDs),tailscaleProps); @override String toString() { - return 'Config(currentProfileId: $currentProfileId, overrideDns: $overrideDns, hotKeyActions: $hotKeyActions, appSettingProps: $appSettingProps, davProps: $davProps, networkProps: $networkProps, vpnProps: $vpnProps, themeProps: $themeProps, proxiesStyleProps: $proxiesStyleProps, windowProps: $windowProps, patchClashConfig: $patchClashConfig, excludeSSIDs: $excludeSSIDs)'; + return 'Config(currentProfileId: $currentProfileId, overrideDns: $overrideDns, hotKeyActions: $hotKeyActions, appSettingProps: $appSettingProps, davProps: $davProps, networkProps: $networkProps, vpnProps: $vpnProps, themeProps: $themeProps, proxiesStyleProps: $proxiesStyleProps, windowProps: $windowProps, patchClashConfig: $patchClashConfig, excludeSSIDs: $excludeSSIDs, tailscaleProps: $tailscaleProps)'; } @@ -2362,11 +2362,11 @@ abstract mixin class $ConfigCopyWith<$Res> { factory $ConfigCopyWith(Config value, $Res Function(Config) _then) = _$ConfigCopyWithImpl; @useResult $Res call({ - int? currentProfileId, bool overrideDns, List hotKeyActions,@JsonKey(fromJson: AppSettingProps.safeFromJson) AppSettingProps appSettingProps, DAVProps? davProps, NetworkProps networkProps, VpnProps vpnProps,@JsonKey(fromJson: ThemeProps.safeFromJson) ThemeProps themeProps, ProxiesStyleProps proxiesStyleProps, WindowProps windowProps, PatchClashConfig patchClashConfig, List excludeSSIDs + int? currentProfileId, bool overrideDns, List hotKeyActions,@JsonKey(fromJson: AppSettingProps.safeFromJson) AppSettingProps appSettingProps, DAVProps? davProps, NetworkProps networkProps, VpnProps vpnProps,@JsonKey(fromJson: ThemeProps.safeFromJson) ThemeProps themeProps, ProxiesStyleProps proxiesStyleProps, WindowProps windowProps, PatchClashConfig patchClashConfig, List excludeSSIDs,@JsonKey(fromJson: TailscaleProps.safeFromJson) TailscaleProps tailscaleProps }); -$AppSettingPropsCopyWith<$Res> get appSettingProps;$DAVPropsCopyWith<$Res>? get davProps;$NetworkPropsCopyWith<$Res> get networkProps;$VpnPropsCopyWith<$Res> get vpnProps;$ThemePropsCopyWith<$Res> get themeProps;$ProxiesStylePropsCopyWith<$Res> get proxiesStyleProps;$WindowPropsCopyWith<$Res> get windowProps;$PatchClashConfigCopyWith<$Res> get patchClashConfig; +$AppSettingPropsCopyWith<$Res> get appSettingProps;$DAVPropsCopyWith<$Res>? get davProps;$NetworkPropsCopyWith<$Res> get networkProps;$VpnPropsCopyWith<$Res> get vpnProps;$ThemePropsCopyWith<$Res> get themeProps;$ProxiesStylePropsCopyWith<$Res> get proxiesStyleProps;$WindowPropsCopyWith<$Res> get windowProps;$PatchClashConfigCopyWith<$Res> get patchClashConfig;$TailscalePropsCopyWith<$Res> get tailscaleProps; } /// @nodoc @@ -2379,7 +2379,7 @@ class _$ConfigCopyWithImpl<$Res> /// Create a copy of Config /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? currentProfileId = freezed,Object? overrideDns = null,Object? hotKeyActions = null,Object? appSettingProps = null,Object? davProps = freezed,Object? networkProps = null,Object? vpnProps = null,Object? themeProps = null,Object? proxiesStyleProps = null,Object? windowProps = null,Object? patchClashConfig = null,Object? excludeSSIDs = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? currentProfileId = freezed,Object? overrideDns = null,Object? hotKeyActions = null,Object? appSettingProps = null,Object? davProps = freezed,Object? networkProps = null,Object? vpnProps = null,Object? themeProps = null,Object? proxiesStyleProps = null,Object? windowProps = null,Object? patchClashConfig = null,Object? excludeSSIDs = null,Object? tailscaleProps = null,}) { return _then(_self.copyWith( currentProfileId: freezed == currentProfileId ? _self.currentProfileId : currentProfileId // ignore: cast_nullable_to_non_nullable as int?,overrideDns: null == overrideDns ? _self.overrideDns : overrideDns // ignore: cast_nullable_to_non_nullable @@ -2393,7 +2393,8 @@ as ThemeProps,proxiesStyleProps: null == proxiesStyleProps ? _self.proxiesStyleP as ProxiesStyleProps,windowProps: null == windowProps ? _self.windowProps : windowProps // ignore: cast_nullable_to_non_nullable as WindowProps,patchClashConfig: null == patchClashConfig ? _self.patchClashConfig : patchClashConfig // ignore: cast_nullable_to_non_nullable as PatchClashConfig,excludeSSIDs: null == excludeSSIDs ? _self.excludeSSIDs : excludeSSIDs // ignore: cast_nullable_to_non_nullable -as List, +as List,tailscaleProps: null == tailscaleProps ? _self.tailscaleProps : tailscaleProps // ignore: cast_nullable_to_non_nullable +as TailscaleProps, )); } /// Create a copy of Config @@ -2471,6 +2472,15 @@ $PatchClashConfigCopyWith<$Res> get patchClashConfig { return $PatchClashConfigCopyWith<$Res>(_self.patchClashConfig, (value) { return _then(_self.copyWith(patchClashConfig: value)); }); +}/// Create a copy of Config +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TailscalePropsCopyWith<$Res> get tailscaleProps { + + return $TailscalePropsCopyWith<$Res>(_self.tailscaleProps, (value) { + return _then(_self.copyWith(tailscaleProps: value)); + }); } } @@ -2553,10 +2563,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( int? currentProfileId, bool overrideDns, List hotKeyActions, @JsonKey(fromJson: AppSettingProps.safeFromJson) AppSettingProps appSettingProps, DAVProps? davProps, NetworkProps networkProps, VpnProps vpnProps, @JsonKey(fromJson: ThemeProps.safeFromJson) ThemeProps themeProps, ProxiesStyleProps proxiesStyleProps, WindowProps windowProps, PatchClashConfig patchClashConfig, List excludeSSIDs)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( int? currentProfileId, bool overrideDns, List hotKeyActions, @JsonKey(fromJson: AppSettingProps.safeFromJson) AppSettingProps appSettingProps, DAVProps? davProps, NetworkProps networkProps, VpnProps vpnProps, @JsonKey(fromJson: ThemeProps.safeFromJson) ThemeProps themeProps, ProxiesStyleProps proxiesStyleProps, WindowProps windowProps, PatchClashConfig patchClashConfig, List excludeSSIDs, @JsonKey(fromJson: TailscaleProps.safeFromJson) TailscaleProps tailscaleProps)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _Config() when $default != null: -return $default(_that.currentProfileId,_that.overrideDns,_that.hotKeyActions,_that.appSettingProps,_that.davProps,_that.networkProps,_that.vpnProps,_that.themeProps,_that.proxiesStyleProps,_that.windowProps,_that.patchClashConfig,_that.excludeSSIDs);case _: +return $default(_that.currentProfileId,_that.overrideDns,_that.hotKeyActions,_that.appSettingProps,_that.davProps,_that.networkProps,_that.vpnProps,_that.themeProps,_that.proxiesStyleProps,_that.windowProps,_that.patchClashConfig,_that.excludeSSIDs,_that.tailscaleProps);case _: return orElse(); } @@ -2574,10 +2584,10 @@ return $default(_that.currentProfileId,_that.overrideDns,_that.hotKeyActions,_th /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( int? currentProfileId, bool overrideDns, List hotKeyActions, @JsonKey(fromJson: AppSettingProps.safeFromJson) AppSettingProps appSettingProps, DAVProps? davProps, NetworkProps networkProps, VpnProps vpnProps, @JsonKey(fromJson: ThemeProps.safeFromJson) ThemeProps themeProps, ProxiesStyleProps proxiesStyleProps, WindowProps windowProps, PatchClashConfig patchClashConfig, List excludeSSIDs) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( int? currentProfileId, bool overrideDns, List hotKeyActions, @JsonKey(fromJson: AppSettingProps.safeFromJson) AppSettingProps appSettingProps, DAVProps? davProps, NetworkProps networkProps, VpnProps vpnProps, @JsonKey(fromJson: ThemeProps.safeFromJson) ThemeProps themeProps, ProxiesStyleProps proxiesStyleProps, WindowProps windowProps, PatchClashConfig patchClashConfig, List excludeSSIDs, @JsonKey(fromJson: TailscaleProps.safeFromJson) TailscaleProps tailscaleProps) $default,) {final _that = this; switch (_that) { case _Config(): -return $default(_that.currentProfileId,_that.overrideDns,_that.hotKeyActions,_that.appSettingProps,_that.davProps,_that.networkProps,_that.vpnProps,_that.themeProps,_that.proxiesStyleProps,_that.windowProps,_that.patchClashConfig,_that.excludeSSIDs);case _: +return $default(_that.currentProfileId,_that.overrideDns,_that.hotKeyActions,_that.appSettingProps,_that.davProps,_that.networkProps,_that.vpnProps,_that.themeProps,_that.proxiesStyleProps,_that.windowProps,_that.patchClashConfig,_that.excludeSSIDs,_that.tailscaleProps);case _: throw StateError('Unexpected subclass'); } @@ -2594,10 +2604,10 @@ return $default(_that.currentProfileId,_that.overrideDns,_that.hotKeyActions,_th /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( int? currentProfileId, bool overrideDns, List hotKeyActions, @JsonKey(fromJson: AppSettingProps.safeFromJson) AppSettingProps appSettingProps, DAVProps? davProps, NetworkProps networkProps, VpnProps vpnProps, @JsonKey(fromJson: ThemeProps.safeFromJson) ThemeProps themeProps, ProxiesStyleProps proxiesStyleProps, WindowProps windowProps, PatchClashConfig patchClashConfig, List excludeSSIDs)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int? currentProfileId, bool overrideDns, List hotKeyActions, @JsonKey(fromJson: AppSettingProps.safeFromJson) AppSettingProps appSettingProps, DAVProps? davProps, NetworkProps networkProps, VpnProps vpnProps, @JsonKey(fromJson: ThemeProps.safeFromJson) ThemeProps themeProps, ProxiesStyleProps proxiesStyleProps, WindowProps windowProps, PatchClashConfig patchClashConfig, List excludeSSIDs, @JsonKey(fromJson: TailscaleProps.safeFromJson) TailscaleProps tailscaleProps)? $default,) {final _that = this; switch (_that) { case _Config() when $default != null: -return $default(_that.currentProfileId,_that.overrideDns,_that.hotKeyActions,_that.appSettingProps,_that.davProps,_that.networkProps,_that.vpnProps,_that.themeProps,_that.proxiesStyleProps,_that.windowProps,_that.patchClashConfig,_that.excludeSSIDs);case _: +return $default(_that.currentProfileId,_that.overrideDns,_that.hotKeyActions,_that.appSettingProps,_that.davProps,_that.networkProps,_that.vpnProps,_that.themeProps,_that.proxiesStyleProps,_that.windowProps,_that.patchClashConfig,_that.excludeSSIDs,_that.tailscaleProps);case _: return null; } @@ -2609,7 +2619,7 @@ return $default(_that.currentProfileId,_that.overrideDns,_that.hotKeyActions,_th @JsonSerializable() class _Config implements Config { - const _Config({this.currentProfileId, this.overrideDns = false, final List hotKeyActions = const [], @JsonKey(fromJson: AppSettingProps.safeFromJson) this.appSettingProps = defaultAppSettingProps, this.davProps, this.networkProps = defaultNetworkProps, this.vpnProps = defaultVpnProps, @JsonKey(fromJson: ThemeProps.safeFromJson) required this.themeProps, this.proxiesStyleProps = defaultProxiesStyleProps, this.windowProps = defaultWindowProps, this.patchClashConfig = defaultClashConfig, final List excludeSSIDs = const []}): _hotKeyActions = hotKeyActions,_excludeSSIDs = excludeSSIDs; + const _Config({this.currentProfileId, this.overrideDns = false, final List hotKeyActions = const [], @JsonKey(fromJson: AppSettingProps.safeFromJson) this.appSettingProps = defaultAppSettingProps, this.davProps, this.networkProps = defaultNetworkProps, this.vpnProps = defaultVpnProps, @JsonKey(fromJson: ThemeProps.safeFromJson) required this.themeProps, this.proxiesStyleProps = defaultProxiesStyleProps, this.windowProps = defaultWindowProps, this.patchClashConfig = defaultClashConfig, final List excludeSSIDs = const [], @JsonKey(fromJson: TailscaleProps.safeFromJson) this.tailscaleProps = defaultTailscaleProps}): _hotKeyActions = hotKeyActions,_excludeSSIDs = excludeSSIDs; factory _Config.fromJson(Map json) => _$ConfigFromJson(json); @override final int? currentProfileId; @@ -2636,6 +2646,7 @@ class _Config implements Config { return EqualUnmodifiableListView(_excludeSSIDs); } +@override@JsonKey(fromJson: TailscaleProps.safeFromJson) final TailscaleProps tailscaleProps; /// Create a copy of Config /// with the given fields replaced by the non-null parameter values. @@ -2650,16 +2661,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _Config&&(identical(other.currentProfileId, currentProfileId) || other.currentProfileId == currentProfileId)&&(identical(other.overrideDns, overrideDns) || other.overrideDns == overrideDns)&&const DeepCollectionEquality().equals(other._hotKeyActions, _hotKeyActions)&&(identical(other.appSettingProps, appSettingProps) || other.appSettingProps == appSettingProps)&&(identical(other.davProps, davProps) || other.davProps == davProps)&&(identical(other.networkProps, networkProps) || other.networkProps == networkProps)&&(identical(other.vpnProps, vpnProps) || other.vpnProps == vpnProps)&&(identical(other.themeProps, themeProps) || other.themeProps == themeProps)&&(identical(other.proxiesStyleProps, proxiesStyleProps) || other.proxiesStyleProps == proxiesStyleProps)&&(identical(other.windowProps, windowProps) || other.windowProps == windowProps)&&(identical(other.patchClashConfig, patchClashConfig) || other.patchClashConfig == patchClashConfig)&&const DeepCollectionEquality().equals(other._excludeSSIDs, _excludeSSIDs)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Config&&(identical(other.currentProfileId, currentProfileId) || other.currentProfileId == currentProfileId)&&(identical(other.overrideDns, overrideDns) || other.overrideDns == overrideDns)&&const DeepCollectionEquality().equals(other._hotKeyActions, _hotKeyActions)&&(identical(other.appSettingProps, appSettingProps) || other.appSettingProps == appSettingProps)&&(identical(other.davProps, davProps) || other.davProps == davProps)&&(identical(other.networkProps, networkProps) || other.networkProps == networkProps)&&(identical(other.vpnProps, vpnProps) || other.vpnProps == vpnProps)&&(identical(other.themeProps, themeProps) || other.themeProps == themeProps)&&(identical(other.proxiesStyleProps, proxiesStyleProps) || other.proxiesStyleProps == proxiesStyleProps)&&(identical(other.windowProps, windowProps) || other.windowProps == windowProps)&&(identical(other.patchClashConfig, patchClashConfig) || other.patchClashConfig == patchClashConfig)&&const DeepCollectionEquality().equals(other._excludeSSIDs, _excludeSSIDs)&&(identical(other.tailscaleProps, tailscaleProps) || other.tailscaleProps == tailscaleProps)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,currentProfileId,overrideDns,const DeepCollectionEquality().hash(_hotKeyActions),appSettingProps,davProps,networkProps,vpnProps,themeProps,proxiesStyleProps,windowProps,patchClashConfig,const DeepCollectionEquality().hash(_excludeSSIDs)); +int get hashCode => Object.hash(runtimeType,currentProfileId,overrideDns,const DeepCollectionEquality().hash(_hotKeyActions),appSettingProps,davProps,networkProps,vpnProps,themeProps,proxiesStyleProps,windowProps,patchClashConfig,const DeepCollectionEquality().hash(_excludeSSIDs),tailscaleProps); @override String toString() { - return 'Config(currentProfileId: $currentProfileId, overrideDns: $overrideDns, hotKeyActions: $hotKeyActions, appSettingProps: $appSettingProps, davProps: $davProps, networkProps: $networkProps, vpnProps: $vpnProps, themeProps: $themeProps, proxiesStyleProps: $proxiesStyleProps, windowProps: $windowProps, patchClashConfig: $patchClashConfig, excludeSSIDs: $excludeSSIDs)'; + return 'Config(currentProfileId: $currentProfileId, overrideDns: $overrideDns, hotKeyActions: $hotKeyActions, appSettingProps: $appSettingProps, davProps: $davProps, networkProps: $networkProps, vpnProps: $vpnProps, themeProps: $themeProps, proxiesStyleProps: $proxiesStyleProps, windowProps: $windowProps, patchClashConfig: $patchClashConfig, excludeSSIDs: $excludeSSIDs, tailscaleProps: $tailscaleProps)'; } @@ -2670,11 +2681,11 @@ abstract mixin class _$ConfigCopyWith<$Res> implements $ConfigCopyWith<$Res> { factory _$ConfigCopyWith(_Config value, $Res Function(_Config) _then) = __$ConfigCopyWithImpl; @override @useResult $Res call({ - int? currentProfileId, bool overrideDns, List hotKeyActions,@JsonKey(fromJson: AppSettingProps.safeFromJson) AppSettingProps appSettingProps, DAVProps? davProps, NetworkProps networkProps, VpnProps vpnProps,@JsonKey(fromJson: ThemeProps.safeFromJson) ThemeProps themeProps, ProxiesStyleProps proxiesStyleProps, WindowProps windowProps, PatchClashConfig patchClashConfig, List excludeSSIDs + int? currentProfileId, bool overrideDns, List hotKeyActions,@JsonKey(fromJson: AppSettingProps.safeFromJson) AppSettingProps appSettingProps, DAVProps? davProps, NetworkProps networkProps, VpnProps vpnProps,@JsonKey(fromJson: ThemeProps.safeFromJson) ThemeProps themeProps, ProxiesStyleProps proxiesStyleProps, WindowProps windowProps, PatchClashConfig patchClashConfig, List excludeSSIDs,@JsonKey(fromJson: TailscaleProps.safeFromJson) TailscaleProps tailscaleProps }); -@override $AppSettingPropsCopyWith<$Res> get appSettingProps;@override $DAVPropsCopyWith<$Res>? get davProps;@override $NetworkPropsCopyWith<$Res> get networkProps;@override $VpnPropsCopyWith<$Res> get vpnProps;@override $ThemePropsCopyWith<$Res> get themeProps;@override $ProxiesStylePropsCopyWith<$Res> get proxiesStyleProps;@override $WindowPropsCopyWith<$Res> get windowProps;@override $PatchClashConfigCopyWith<$Res> get patchClashConfig; +@override $AppSettingPropsCopyWith<$Res> get appSettingProps;@override $DAVPropsCopyWith<$Res>? get davProps;@override $NetworkPropsCopyWith<$Res> get networkProps;@override $VpnPropsCopyWith<$Res> get vpnProps;@override $ThemePropsCopyWith<$Res> get themeProps;@override $ProxiesStylePropsCopyWith<$Res> get proxiesStyleProps;@override $WindowPropsCopyWith<$Res> get windowProps;@override $PatchClashConfigCopyWith<$Res> get patchClashConfig;@override $TailscalePropsCopyWith<$Res> get tailscaleProps; } /// @nodoc @@ -2687,7 +2698,7 @@ class __$ConfigCopyWithImpl<$Res> /// Create a copy of Config /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? currentProfileId = freezed,Object? overrideDns = null,Object? hotKeyActions = null,Object? appSettingProps = null,Object? davProps = freezed,Object? networkProps = null,Object? vpnProps = null,Object? themeProps = null,Object? proxiesStyleProps = null,Object? windowProps = null,Object? patchClashConfig = null,Object? excludeSSIDs = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? currentProfileId = freezed,Object? overrideDns = null,Object? hotKeyActions = null,Object? appSettingProps = null,Object? davProps = freezed,Object? networkProps = null,Object? vpnProps = null,Object? themeProps = null,Object? proxiesStyleProps = null,Object? windowProps = null,Object? patchClashConfig = null,Object? excludeSSIDs = null,Object? tailscaleProps = null,}) { return _then(_Config( currentProfileId: freezed == currentProfileId ? _self.currentProfileId : currentProfileId // ignore: cast_nullable_to_non_nullable as int?,overrideDns: null == overrideDns ? _self.overrideDns : overrideDns // ignore: cast_nullable_to_non_nullable @@ -2701,7 +2712,8 @@ as ThemeProps,proxiesStyleProps: null == proxiesStyleProps ? _self.proxiesStyleP as ProxiesStyleProps,windowProps: null == windowProps ? _self.windowProps : windowProps // ignore: cast_nullable_to_non_nullable as WindowProps,patchClashConfig: null == patchClashConfig ? _self.patchClashConfig : patchClashConfig // ignore: cast_nullable_to_non_nullable as PatchClashConfig,excludeSSIDs: null == excludeSSIDs ? _self._excludeSSIDs : excludeSSIDs // ignore: cast_nullable_to_non_nullable -as List, +as List,tailscaleProps: null == tailscaleProps ? _self.tailscaleProps : tailscaleProps // ignore: cast_nullable_to_non_nullable +as TailscaleProps, )); } @@ -2780,6 +2792,15 @@ $PatchClashConfigCopyWith<$Res> get patchClashConfig { return $PatchClashConfigCopyWith<$Res>(_self.patchClashConfig, (value) { return _then(_self.copyWith(patchClashConfig: value)); }); +}/// Create a copy of Config +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TailscalePropsCopyWith<$Res> get tailscaleProps { + + return $TailscalePropsCopyWith<$Res>(_self.tailscaleProps, (value) { + return _then(_self.copyWith(tailscaleProps: value)); + }); } } diff --git a/lib/models/generated/config.g.dart b/lib/models/generated/config.g.dart index 76d3d60445..d2aec8f296 100644 --- a/lib/models/generated/config.g.dart +++ b/lib/models/generated/config.g.dart @@ -349,6 +349,11 @@ _Config _$ConfigFromJson(Map json) => _Config( ?.map((e) => e as String) .toList() ?? const [], + tailscaleProps: json['tailscaleProps'] == null + ? defaultTailscaleProps + : TailscaleProps.safeFromJson( + json['tailscaleProps'] as Map?, + ), ); Map _$ConfigToJson(_Config instance) => { @@ -364,4 +369,5 @@ Map _$ConfigToJson(_Config instance) => { 'windowProps': instance.windowProps, 'patchClashConfig': instance.patchClashConfig, 'excludeSSIDs': instance.excludeSSIDs, + 'tailscaleProps': instance.tailscaleProps, }; 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/models/generated/state.freezed.dart b/lib/models/generated/state.freezed.dart index ae57583f99..e0d0809a66 100644 --- a/lib/models/generated/state.freezed.dart +++ b/lib/models/generated/state.freezed.dart @@ -9062,7 +9062,7 @@ $ProxiesDataCopyWith<$Res> get proxiesData { /// @nodoc mixin _$MakeRealProfileState { - String get profilesPath; int get profileId; Map get rawConfig; PatchClashConfig get realPatchConfig; bool get overrideDns; bool get appendSystemDns; List get proxyGroups; List get rules; List get addedRules; String get defaultUA; + String get profilesPath; int get profileId; Map get rawConfig; PatchClashConfig get realPatchConfig; bool get overrideDns; bool get appendSystemDns; List get proxyGroups; List get rules; List get addedRules; String get defaultUA; List get tailscaleProxies; List get tailscaleRules; List get tailscaleFakeIpFilters; /// Create a copy of MakeRealProfileState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -9073,16 +9073,16 @@ $MakeRealProfileStateCopyWith get copyWith => _$MakeRealPr @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is MakeRealProfileState&&(identical(other.profilesPath, profilesPath) || other.profilesPath == profilesPath)&&(identical(other.profileId, profileId) || other.profileId == profileId)&&const DeepCollectionEquality().equals(other.rawConfig, rawConfig)&&(identical(other.realPatchConfig, realPatchConfig) || other.realPatchConfig == realPatchConfig)&&(identical(other.overrideDns, overrideDns) || other.overrideDns == overrideDns)&&(identical(other.appendSystemDns, appendSystemDns) || other.appendSystemDns == appendSystemDns)&&const DeepCollectionEquality().equals(other.proxyGroups, proxyGroups)&&const DeepCollectionEquality().equals(other.rules, rules)&&const DeepCollectionEquality().equals(other.addedRules, addedRules)&&(identical(other.defaultUA, defaultUA) || other.defaultUA == defaultUA)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is MakeRealProfileState&&(identical(other.profilesPath, profilesPath) || other.profilesPath == profilesPath)&&(identical(other.profileId, profileId) || other.profileId == profileId)&&const DeepCollectionEquality().equals(other.rawConfig, rawConfig)&&(identical(other.realPatchConfig, realPatchConfig) || other.realPatchConfig == realPatchConfig)&&(identical(other.overrideDns, overrideDns) || other.overrideDns == overrideDns)&&(identical(other.appendSystemDns, appendSystemDns) || other.appendSystemDns == appendSystemDns)&&const DeepCollectionEquality().equals(other.proxyGroups, proxyGroups)&&const DeepCollectionEquality().equals(other.rules, rules)&&const DeepCollectionEquality().equals(other.addedRules, addedRules)&&(identical(other.defaultUA, defaultUA) || other.defaultUA == defaultUA)&&const DeepCollectionEquality().equals(other.tailscaleProxies, tailscaleProxies)&&const DeepCollectionEquality().equals(other.tailscaleRules, tailscaleRules)&&const DeepCollectionEquality().equals(other.tailscaleFakeIpFilters, tailscaleFakeIpFilters)); } @override -int get hashCode => Object.hash(runtimeType,profilesPath,profileId,const DeepCollectionEquality().hash(rawConfig),realPatchConfig,overrideDns,appendSystemDns,const DeepCollectionEquality().hash(proxyGroups),const DeepCollectionEquality().hash(rules),const DeepCollectionEquality().hash(addedRules),defaultUA); +int get hashCode => Object.hash(runtimeType,profilesPath,profileId,const DeepCollectionEquality().hash(rawConfig),realPatchConfig,overrideDns,appendSystemDns,const DeepCollectionEquality().hash(proxyGroups),const DeepCollectionEquality().hash(rules),const DeepCollectionEquality().hash(addedRules),defaultUA,const DeepCollectionEquality().hash(tailscaleProxies),const DeepCollectionEquality().hash(tailscaleRules),const DeepCollectionEquality().hash(tailscaleFakeIpFilters)); @override String toString() { - return 'MakeRealProfileState(profilesPath: $profilesPath, profileId: $profileId, rawConfig: $rawConfig, realPatchConfig: $realPatchConfig, overrideDns: $overrideDns, appendSystemDns: $appendSystemDns, proxyGroups: $proxyGroups, rules: $rules, addedRules: $addedRules, defaultUA: $defaultUA)'; + return 'MakeRealProfileState(profilesPath: $profilesPath, profileId: $profileId, rawConfig: $rawConfig, realPatchConfig: $realPatchConfig, overrideDns: $overrideDns, appendSystemDns: $appendSystemDns, proxyGroups: $proxyGroups, rules: $rules, addedRules: $addedRules, defaultUA: $defaultUA, tailscaleProxies: $tailscaleProxies, tailscaleRules: $tailscaleRules, tailscaleFakeIpFilters: $tailscaleFakeIpFilters)'; } @@ -9093,7 +9093,7 @@ abstract mixin class $MakeRealProfileStateCopyWith<$Res> { factory $MakeRealProfileStateCopyWith(MakeRealProfileState value, $Res Function(MakeRealProfileState) _then) = _$MakeRealProfileStateCopyWithImpl; @useResult $Res call({ - String profilesPath, int profileId, Map rawConfig, PatchClashConfig realPatchConfig, bool overrideDns, bool appendSystemDns, List proxyGroups, List rules, List addedRules, String defaultUA + String profilesPath, int profileId, Map rawConfig, PatchClashConfig realPatchConfig, bool overrideDns, bool appendSystemDns, List proxyGroups, List rules, List addedRules, String defaultUA, List tailscaleProxies, List tailscaleRules, List tailscaleFakeIpFilters }); @@ -9110,7 +9110,7 @@ class _$MakeRealProfileStateCopyWithImpl<$Res> /// Create a copy of MakeRealProfileState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? profilesPath = null,Object? profileId = null,Object? rawConfig = null,Object? realPatchConfig = null,Object? overrideDns = null,Object? appendSystemDns = null,Object? proxyGroups = null,Object? rules = null,Object? addedRules = null,Object? defaultUA = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? profilesPath = null,Object? profileId = null,Object? rawConfig = null,Object? realPatchConfig = null,Object? overrideDns = null,Object? appendSystemDns = null,Object? proxyGroups = null,Object? rules = null,Object? addedRules = null,Object? defaultUA = null,Object? tailscaleProxies = null,Object? tailscaleRules = null,Object? tailscaleFakeIpFilters = null,}) { return _then(_self.copyWith( profilesPath: null == profilesPath ? _self.profilesPath : profilesPath // ignore: cast_nullable_to_non_nullable as String,profileId: null == profileId ? _self.profileId : profileId // ignore: cast_nullable_to_non_nullable @@ -9122,7 +9122,10 @@ as bool,proxyGroups: null == proxyGroups ? _self.proxyGroups : proxyGroups // ig as List,rules: null == rules ? _self.rules : rules // ignore: cast_nullable_to_non_nullable as List,addedRules: null == addedRules ? _self.addedRules : addedRules // ignore: cast_nullable_to_non_nullable as List,defaultUA: null == defaultUA ? _self.defaultUA : defaultUA // ignore: cast_nullable_to_non_nullable -as String, +as String,tailscaleProxies: null == tailscaleProxies ? _self.tailscaleProxies : tailscaleProxies // ignore: cast_nullable_to_non_nullable +as List,tailscaleRules: null == tailscaleRules ? _self.tailscaleRules : tailscaleRules // ignore: cast_nullable_to_non_nullable +as List,tailscaleFakeIpFilters: null == tailscaleFakeIpFilters ? _self.tailscaleFakeIpFilters : tailscaleFakeIpFilters // ignore: cast_nullable_to_non_nullable +as List, )); } /// Create a copy of MakeRealProfileState @@ -9216,10 +9219,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String profilesPath, int profileId, Map rawConfig, PatchClashConfig realPatchConfig, bool overrideDns, bool appendSystemDns, List proxyGroups, List rules, List addedRules, String defaultUA)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String profilesPath, int profileId, Map rawConfig, PatchClashConfig realPatchConfig, bool overrideDns, bool appendSystemDns, List proxyGroups, List rules, List addedRules, String defaultUA, List tailscaleProxies, List tailscaleRules, List tailscaleFakeIpFilters)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _MakeRealProfileState() when $default != null: -return $default(_that.profilesPath,_that.profileId,_that.rawConfig,_that.realPatchConfig,_that.overrideDns,_that.appendSystemDns,_that.proxyGroups,_that.rules,_that.addedRules,_that.defaultUA);case _: +return $default(_that.profilesPath,_that.profileId,_that.rawConfig,_that.realPatchConfig,_that.overrideDns,_that.appendSystemDns,_that.proxyGroups,_that.rules,_that.addedRules,_that.defaultUA,_that.tailscaleProxies,_that.tailscaleRules,_that.tailscaleFakeIpFilters);case _: return orElse(); } @@ -9237,10 +9240,10 @@ return $default(_that.profilesPath,_that.profileId,_that.rawConfig,_that.realPat /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String profilesPath, int profileId, Map rawConfig, PatchClashConfig realPatchConfig, bool overrideDns, bool appendSystemDns, List proxyGroups, List rules, List addedRules, String defaultUA) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String profilesPath, int profileId, Map rawConfig, PatchClashConfig realPatchConfig, bool overrideDns, bool appendSystemDns, List proxyGroups, List rules, List addedRules, String defaultUA, List tailscaleProxies, List tailscaleRules, List tailscaleFakeIpFilters) $default,) {final _that = this; switch (_that) { case _MakeRealProfileState(): -return $default(_that.profilesPath,_that.profileId,_that.rawConfig,_that.realPatchConfig,_that.overrideDns,_that.appendSystemDns,_that.proxyGroups,_that.rules,_that.addedRules,_that.defaultUA);case _: +return $default(_that.profilesPath,_that.profileId,_that.rawConfig,_that.realPatchConfig,_that.overrideDns,_that.appendSystemDns,_that.proxyGroups,_that.rules,_that.addedRules,_that.defaultUA,_that.tailscaleProxies,_that.tailscaleRules,_that.tailscaleFakeIpFilters);case _: throw StateError('Unexpected subclass'); } @@ -9257,10 +9260,10 @@ return $default(_that.profilesPath,_that.profileId,_that.rawConfig,_that.realPat /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String profilesPath, int profileId, Map rawConfig, PatchClashConfig realPatchConfig, bool overrideDns, bool appendSystemDns, List proxyGroups, List rules, List addedRules, String defaultUA)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String profilesPath, int profileId, Map rawConfig, PatchClashConfig realPatchConfig, bool overrideDns, bool appendSystemDns, List proxyGroups, List rules, List addedRules, String defaultUA, List tailscaleProxies, List tailscaleRules, List tailscaleFakeIpFilters)? $default,) {final _that = this; switch (_that) { case _MakeRealProfileState() when $default != null: -return $default(_that.profilesPath,_that.profileId,_that.rawConfig,_that.realPatchConfig,_that.overrideDns,_that.appendSystemDns,_that.proxyGroups,_that.rules,_that.addedRules,_that.defaultUA);case _: +return $default(_that.profilesPath,_that.profileId,_that.rawConfig,_that.realPatchConfig,_that.overrideDns,_that.appendSystemDns,_that.proxyGroups,_that.rules,_that.addedRules,_that.defaultUA,_that.tailscaleProxies,_that.tailscaleRules,_that.tailscaleFakeIpFilters);case _: return null; } @@ -9272,7 +9275,7 @@ return $default(_that.profilesPath,_that.profileId,_that.rawConfig,_that.realPat class _MakeRealProfileState implements MakeRealProfileState { - const _MakeRealProfileState({required this.profilesPath, required this.profileId, required final Map rawConfig, required this.realPatchConfig, required this.overrideDns, required this.appendSystemDns, required final List proxyGroups, required final List rules, required final List addedRules, required this.defaultUA}): _rawConfig = rawConfig,_proxyGroups = proxyGroups,_rules = rules,_addedRules = addedRules; + const _MakeRealProfileState({required this.profilesPath, required this.profileId, required final Map rawConfig, required this.realPatchConfig, required this.overrideDns, required this.appendSystemDns, required final List proxyGroups, required final List rules, required final List addedRules, required this.defaultUA, final List tailscaleProxies = const [], final List tailscaleRules = const [], final List tailscaleFakeIpFilters = const []}): _rawConfig = rawConfig,_proxyGroups = proxyGroups,_rules = rules,_addedRules = addedRules,_tailscaleProxies = tailscaleProxies,_tailscaleRules = tailscaleRules,_tailscaleFakeIpFilters = tailscaleFakeIpFilters; @override final String profilesPath; @@ -9309,6 +9312,27 @@ class _MakeRealProfileState implements MakeRealProfileState { } @override final String defaultUA; + final List _tailscaleProxies; +@override@JsonKey() List get tailscaleProxies { + if (_tailscaleProxies is EqualUnmodifiableListView) return _tailscaleProxies; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tailscaleProxies); +} + + final List _tailscaleRules; +@override@JsonKey() List get tailscaleRules { + if (_tailscaleRules is EqualUnmodifiableListView) return _tailscaleRules; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tailscaleRules); +} + + final List _tailscaleFakeIpFilters; +@override@JsonKey() List get tailscaleFakeIpFilters { + if (_tailscaleFakeIpFilters is EqualUnmodifiableListView) return _tailscaleFakeIpFilters; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tailscaleFakeIpFilters); +} + /// Create a copy of MakeRealProfileState /// with the given fields replaced by the non-null parameter values. @@ -9320,16 +9344,16 @@ _$MakeRealProfileStateCopyWith<_MakeRealProfileState> get copyWith => __$MakeRea @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _MakeRealProfileState&&(identical(other.profilesPath, profilesPath) || other.profilesPath == profilesPath)&&(identical(other.profileId, profileId) || other.profileId == profileId)&&const DeepCollectionEquality().equals(other._rawConfig, _rawConfig)&&(identical(other.realPatchConfig, realPatchConfig) || other.realPatchConfig == realPatchConfig)&&(identical(other.overrideDns, overrideDns) || other.overrideDns == overrideDns)&&(identical(other.appendSystemDns, appendSystemDns) || other.appendSystemDns == appendSystemDns)&&const DeepCollectionEquality().equals(other._proxyGroups, _proxyGroups)&&const DeepCollectionEquality().equals(other._rules, _rules)&&const DeepCollectionEquality().equals(other._addedRules, _addedRules)&&(identical(other.defaultUA, defaultUA) || other.defaultUA == defaultUA)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MakeRealProfileState&&(identical(other.profilesPath, profilesPath) || other.profilesPath == profilesPath)&&(identical(other.profileId, profileId) || other.profileId == profileId)&&const DeepCollectionEquality().equals(other._rawConfig, _rawConfig)&&(identical(other.realPatchConfig, realPatchConfig) || other.realPatchConfig == realPatchConfig)&&(identical(other.overrideDns, overrideDns) || other.overrideDns == overrideDns)&&(identical(other.appendSystemDns, appendSystemDns) || other.appendSystemDns == appendSystemDns)&&const DeepCollectionEquality().equals(other._proxyGroups, _proxyGroups)&&const DeepCollectionEquality().equals(other._rules, _rules)&&const DeepCollectionEquality().equals(other._addedRules, _addedRules)&&(identical(other.defaultUA, defaultUA) || other.defaultUA == defaultUA)&&const DeepCollectionEquality().equals(other._tailscaleProxies, _tailscaleProxies)&&const DeepCollectionEquality().equals(other._tailscaleRules, _tailscaleRules)&&const DeepCollectionEquality().equals(other._tailscaleFakeIpFilters, _tailscaleFakeIpFilters)); } @override -int get hashCode => Object.hash(runtimeType,profilesPath,profileId,const DeepCollectionEquality().hash(_rawConfig),realPatchConfig,overrideDns,appendSystemDns,const DeepCollectionEquality().hash(_proxyGroups),const DeepCollectionEquality().hash(_rules),const DeepCollectionEquality().hash(_addedRules),defaultUA); +int get hashCode => Object.hash(runtimeType,profilesPath,profileId,const DeepCollectionEquality().hash(_rawConfig),realPatchConfig,overrideDns,appendSystemDns,const DeepCollectionEquality().hash(_proxyGroups),const DeepCollectionEquality().hash(_rules),const DeepCollectionEquality().hash(_addedRules),defaultUA,const DeepCollectionEquality().hash(_tailscaleProxies),const DeepCollectionEquality().hash(_tailscaleRules),const DeepCollectionEquality().hash(_tailscaleFakeIpFilters)); @override String toString() { - return 'MakeRealProfileState(profilesPath: $profilesPath, profileId: $profileId, rawConfig: $rawConfig, realPatchConfig: $realPatchConfig, overrideDns: $overrideDns, appendSystemDns: $appendSystemDns, proxyGroups: $proxyGroups, rules: $rules, addedRules: $addedRules, defaultUA: $defaultUA)'; + return 'MakeRealProfileState(profilesPath: $profilesPath, profileId: $profileId, rawConfig: $rawConfig, realPatchConfig: $realPatchConfig, overrideDns: $overrideDns, appendSystemDns: $appendSystemDns, proxyGroups: $proxyGroups, rules: $rules, addedRules: $addedRules, defaultUA: $defaultUA, tailscaleProxies: $tailscaleProxies, tailscaleRules: $tailscaleRules, tailscaleFakeIpFilters: $tailscaleFakeIpFilters)'; } @@ -9340,7 +9364,7 @@ abstract mixin class _$MakeRealProfileStateCopyWith<$Res> implements $MakeRealPr factory _$MakeRealProfileStateCopyWith(_MakeRealProfileState value, $Res Function(_MakeRealProfileState) _then) = __$MakeRealProfileStateCopyWithImpl; @override @useResult $Res call({ - String profilesPath, int profileId, Map rawConfig, PatchClashConfig realPatchConfig, bool overrideDns, bool appendSystemDns, List proxyGroups, List rules, List addedRules, String defaultUA + String profilesPath, int profileId, Map rawConfig, PatchClashConfig realPatchConfig, bool overrideDns, bool appendSystemDns, List proxyGroups, List rules, List addedRules, String defaultUA, List tailscaleProxies, List tailscaleRules, List tailscaleFakeIpFilters }); @@ -9357,7 +9381,7 @@ class __$MakeRealProfileStateCopyWithImpl<$Res> /// Create a copy of MakeRealProfileState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? profilesPath = null,Object? profileId = null,Object? rawConfig = null,Object? realPatchConfig = null,Object? overrideDns = null,Object? appendSystemDns = null,Object? proxyGroups = null,Object? rules = null,Object? addedRules = null,Object? defaultUA = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? profilesPath = null,Object? profileId = null,Object? rawConfig = null,Object? realPatchConfig = null,Object? overrideDns = null,Object? appendSystemDns = null,Object? proxyGroups = null,Object? rules = null,Object? addedRules = null,Object? defaultUA = null,Object? tailscaleProxies = null,Object? tailscaleRules = null,Object? tailscaleFakeIpFilters = null,}) { return _then(_MakeRealProfileState( profilesPath: null == profilesPath ? _self.profilesPath : profilesPath // ignore: cast_nullable_to_non_nullable as String,profileId: null == profileId ? _self.profileId : profileId // ignore: cast_nullable_to_non_nullable @@ -9369,7 +9393,10 @@ as bool,proxyGroups: null == proxyGroups ? _self._proxyGroups : proxyGroups // i as List,rules: null == rules ? _self._rules : rules // ignore: cast_nullable_to_non_nullable as List,addedRules: null == addedRules ? _self._addedRules : addedRules // ignore: cast_nullable_to_non_nullable as List,defaultUA: null == defaultUA ? _self.defaultUA : defaultUA // ignore: cast_nullable_to_non_nullable -as String, +as String,tailscaleProxies: null == tailscaleProxies ? _self._tailscaleProxies : tailscaleProxies // ignore: cast_nullable_to_non_nullable +as List,tailscaleRules: null == tailscaleRules ? _self._tailscaleRules : tailscaleRules // ignore: cast_nullable_to_non_nullable +as List,tailscaleFakeIpFilters: null == tailscaleFakeIpFilters ? _self._tailscaleFakeIpFilters : tailscaleFakeIpFilters // ignore: cast_nullable_to_non_nullable +as List, )); } diff --git a/lib/models/generated/tailscale.freezed.dart b/lib/models/generated/tailscale.freezed.dart new file mode 100644 index 0000000000..1d2c8db2fb --- /dev/null +++ b/lib/models/generated/tailscale.freezed.dart @@ -0,0 +1,588 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../tailscale.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$TailscaleProps { + + bool get enable; bool get bypassTraffic; List get proxies; +/// Create a copy of TailscaleProps +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TailscalePropsCopyWith get copyWith => _$TailscalePropsCopyWithImpl(this as TailscaleProps, _$identity); + + /// Serializes this TailscaleProps to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TailscaleProps&&(identical(other.enable, enable) || other.enable == enable)&&(identical(other.bypassTraffic, bypassTraffic) || other.bypassTraffic == bypassTraffic)&&const DeepCollectionEquality().equals(other.proxies, proxies)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,enable,bypassTraffic,const DeepCollectionEquality().hash(proxies)); + +@override +String toString() { + return 'TailscaleProps(enable: $enable, bypassTraffic: $bypassTraffic, proxies: $proxies)'; +} + + +} + +/// @nodoc +abstract mixin class $TailscalePropsCopyWith<$Res> { + factory $TailscalePropsCopyWith(TailscaleProps value, $Res Function(TailscaleProps) _then) = _$TailscalePropsCopyWithImpl; +@useResult +$Res call({ + bool enable, bool bypassTraffic, List proxies +}); + + + + +} +/// @nodoc +class _$TailscalePropsCopyWithImpl<$Res> + implements $TailscalePropsCopyWith<$Res> { + _$TailscalePropsCopyWithImpl(this._self, this._then); + + final TailscaleProps _self; + final $Res Function(TailscaleProps) _then; + +/// Create a copy of TailscaleProps +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? enable = null,Object? bypassTraffic = null,Object? proxies = null,}) { + return _then(_self.copyWith( +enable: null == enable ? _self.enable : enable // ignore: cast_nullable_to_non_nullable +as bool,bypassTraffic: null == bypassTraffic ? _self.bypassTraffic : bypassTraffic // ignore: cast_nullable_to_non_nullable +as bool,proxies: null == proxies ? _self.proxies : proxies // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TailscaleProps]. +extension TailscalePropsPatterns on TailscaleProps { +/// 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( _TailscaleProps value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TailscaleProps() 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( _TailscaleProps value) $default,){ +final _that = this; +switch (_that) { +case _TailscaleProps(): +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( _TailscaleProps value)? $default,){ +final _that = this; +switch (_that) { +case _TailscaleProps() 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( bool enable, bool bypassTraffic, List proxies)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TailscaleProps() when $default != null: +return $default(_that.enable,_that.bypassTraffic,_that.proxies);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( bool enable, bool bypassTraffic, List proxies) $default,) {final _that = this; +switch (_that) { +case _TailscaleProps(): +return $default(_that.enable,_that.bypassTraffic,_that.proxies);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( bool enable, bool bypassTraffic, List proxies)? $default,) {final _that = this; +switch (_that) { +case _TailscaleProps() when $default != null: +return $default(_that.enable,_that.bypassTraffic,_that.proxies);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TailscaleProps implements TailscaleProps { + const _TailscaleProps({this.enable = false, this.bypassTraffic = false, final List proxies = const []}): _proxies = proxies; + factory _TailscaleProps.fromJson(Map json) => _$TailscalePropsFromJson(json); + +@override@JsonKey() final bool enable; +@override@JsonKey() final bool bypassTraffic; + final List _proxies; +@override@JsonKey() List get proxies { + if (_proxies is EqualUnmodifiableListView) return _proxies; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_proxies); +} + + +/// Create a copy of TailscaleProps +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TailscalePropsCopyWith<_TailscaleProps> get copyWith => __$TailscalePropsCopyWithImpl<_TailscaleProps>(this, _$identity); + +@override +Map toJson() { + return _$TailscalePropsToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TailscaleProps&&(identical(other.enable, enable) || other.enable == enable)&&(identical(other.bypassTraffic, bypassTraffic) || other.bypassTraffic == bypassTraffic)&&const DeepCollectionEquality().equals(other._proxies, _proxies)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,enable,bypassTraffic,const DeepCollectionEquality().hash(_proxies)); + +@override +String toString() { + return 'TailscaleProps(enable: $enable, bypassTraffic: $bypassTraffic, proxies: $proxies)'; +} + + +} + +/// @nodoc +abstract mixin class _$TailscalePropsCopyWith<$Res> implements $TailscalePropsCopyWith<$Res> { + factory _$TailscalePropsCopyWith(_TailscaleProps value, $Res Function(_TailscaleProps) _then) = __$TailscalePropsCopyWithImpl; +@override @useResult +$Res call({ + bool enable, bool bypassTraffic, List proxies +}); + + + + +} +/// @nodoc +class __$TailscalePropsCopyWithImpl<$Res> + implements _$TailscalePropsCopyWith<$Res> { + __$TailscalePropsCopyWithImpl(this._self, this._then); + + final _TailscaleProps _self; + final $Res Function(_TailscaleProps) _then; + +/// Create a copy of TailscaleProps +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? enable = null,Object? bypassTraffic = null,Object? proxies = null,}) { + return _then(_TailscaleProps( +enable: null == enable ? _self.enable : enable // ignore: cast_nullable_to_non_nullable +as bool,bypassTraffic: null == bypassTraffic ? _self.bypassTraffic : bypassTraffic // ignore: cast_nullable_to_non_nullable +as bool,proxies: null == proxies ? _self._proxies : proxies // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$TailscaleProxy { + + String get name; String get authKey; String get hostname; String get controlUrl; String get stateDir; bool get ephemeral; bool get udp; bool get acceptRoutes; String get exitNode; bool get exitNodeAllowLanAccess; List get routes; +/// Create a copy of TailscaleProxy +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TailscaleProxyCopyWith get copyWith => _$TailscaleProxyCopyWithImpl(this as TailscaleProxy, _$identity); + + /// Serializes this TailscaleProxy to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TailscaleProxy&&(identical(other.name, name) || other.name == name)&&(identical(other.authKey, authKey) || other.authKey == authKey)&&(identical(other.hostname, hostname) || other.hostname == hostname)&&(identical(other.controlUrl, controlUrl) || other.controlUrl == controlUrl)&&(identical(other.stateDir, stateDir) || other.stateDir == stateDir)&&(identical(other.ephemeral, ephemeral) || other.ephemeral == ephemeral)&&(identical(other.udp, udp) || other.udp == udp)&&(identical(other.acceptRoutes, acceptRoutes) || other.acceptRoutes == acceptRoutes)&&(identical(other.exitNode, exitNode) || other.exitNode == exitNode)&&(identical(other.exitNodeAllowLanAccess, exitNodeAllowLanAccess) || other.exitNodeAllowLanAccess == exitNodeAllowLanAccess)&&const DeepCollectionEquality().equals(other.routes, routes)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,authKey,hostname,controlUrl,stateDir,ephemeral,udp,acceptRoutes,exitNode,exitNodeAllowLanAccess,const DeepCollectionEquality().hash(routes)); + +@override +String toString() { + return 'TailscaleProxy(name: $name, authKey: $authKey, hostname: $hostname, controlUrl: $controlUrl, stateDir: $stateDir, ephemeral: $ephemeral, udp: $udp, acceptRoutes: $acceptRoutes, exitNode: $exitNode, exitNodeAllowLanAccess: $exitNodeAllowLanAccess, routes: $routes)'; +} + + +} + +/// @nodoc +abstract mixin class $TailscaleProxyCopyWith<$Res> { + factory $TailscaleProxyCopyWith(TailscaleProxy value, $Res Function(TailscaleProxy) _then) = _$TailscaleProxyCopyWithImpl; +@useResult +$Res call({ + String name, String authKey, String hostname, String controlUrl, String stateDir, bool ephemeral, bool udp, bool acceptRoutes, String exitNode, bool exitNodeAllowLanAccess, List routes +}); + + + + +} +/// @nodoc +class _$TailscaleProxyCopyWithImpl<$Res> + implements $TailscaleProxyCopyWith<$Res> { + _$TailscaleProxyCopyWithImpl(this._self, this._then); + + final TailscaleProxy _self; + final $Res Function(TailscaleProxy) _then; + +/// Create a copy of TailscaleProxy +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? authKey = null,Object? hostname = null,Object? controlUrl = null,Object? stateDir = null,Object? ephemeral = null,Object? udp = null,Object? acceptRoutes = null,Object? exitNode = null,Object? exitNodeAllowLanAccess = null,Object? routes = null,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,authKey: null == authKey ? _self.authKey : authKey // ignore: cast_nullable_to_non_nullable +as String,hostname: null == hostname ? _self.hostname : hostname // ignore: cast_nullable_to_non_nullable +as String,controlUrl: null == controlUrl ? _self.controlUrl : controlUrl // ignore: cast_nullable_to_non_nullable +as String,stateDir: null == stateDir ? _self.stateDir : stateDir // ignore: cast_nullable_to_non_nullable +as String,ephemeral: null == ephemeral ? _self.ephemeral : ephemeral // ignore: cast_nullable_to_non_nullable +as bool,udp: null == udp ? _self.udp : udp // ignore: cast_nullable_to_non_nullable +as bool,acceptRoutes: null == acceptRoutes ? _self.acceptRoutes : acceptRoutes // ignore: cast_nullable_to_non_nullable +as bool,exitNode: null == exitNode ? _self.exitNode : exitNode // ignore: cast_nullable_to_non_nullable +as String,exitNodeAllowLanAccess: null == exitNodeAllowLanAccess ? _self.exitNodeAllowLanAccess : exitNodeAllowLanAccess // ignore: cast_nullable_to_non_nullable +as bool,routes: null == routes ? _self.routes : routes // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TailscaleProxy]. +extension TailscaleProxyPatterns on TailscaleProxy { +/// 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( _TailscaleProxy value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TailscaleProxy() 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( _TailscaleProxy value) $default,){ +final _that = this; +switch (_that) { +case _TailscaleProxy(): +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( _TailscaleProxy value)? $default,){ +final _that = this; +switch (_that) { +case _TailscaleProxy() 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( String name, String authKey, String hostname, String controlUrl, String stateDir, bool ephemeral, bool udp, bool acceptRoutes, String exitNode, bool exitNodeAllowLanAccess, List routes)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TailscaleProxy() when $default != null: +return $default(_that.name,_that.authKey,_that.hostname,_that.controlUrl,_that.stateDir,_that.ephemeral,_that.udp,_that.acceptRoutes,_that.exitNode,_that.exitNodeAllowLanAccess,_that.routes);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( String name, String authKey, String hostname, String controlUrl, String stateDir, bool ephemeral, bool udp, bool acceptRoutes, String exitNode, bool exitNodeAllowLanAccess, List routes) $default,) {final _that = this; +switch (_that) { +case _TailscaleProxy(): +return $default(_that.name,_that.authKey,_that.hostname,_that.controlUrl,_that.stateDir,_that.ephemeral,_that.udp,_that.acceptRoutes,_that.exitNode,_that.exitNodeAllowLanAccess,_that.routes);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( String name, String authKey, String hostname, String controlUrl, String stateDir, bool ephemeral, bool udp, bool acceptRoutes, String exitNode, bool exitNodeAllowLanAccess, List routes)? $default,) {final _that = this; +switch (_that) { +case _TailscaleProxy() when $default != null: +return $default(_that.name,_that.authKey,_that.hostname,_that.controlUrl,_that.stateDir,_that.ephemeral,_that.udp,_that.acceptRoutes,_that.exitNode,_that.exitNodeAllowLanAccess,_that.routes);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TailscaleProxy implements TailscaleProxy { + const _TailscaleProxy({required this.name, this.authKey = '', this.hostname = '', this.controlUrl = '', this.stateDir = '', this.ephemeral = false, this.udp = false, this.acceptRoutes = false, this.exitNode = '', this.exitNodeAllowLanAccess = false, final List routes = const []}): _routes = routes; + factory _TailscaleProxy.fromJson(Map json) => _$TailscaleProxyFromJson(json); + +@override final String name; +@override@JsonKey() final String authKey; +@override@JsonKey() final String hostname; +@override@JsonKey() final String controlUrl; +@override@JsonKey() final String stateDir; +@override@JsonKey() final bool ephemeral; +@override@JsonKey() final bool udp; +@override@JsonKey() final bool acceptRoutes; +@override@JsonKey() final String exitNode; +@override@JsonKey() final bool exitNodeAllowLanAccess; + final List _routes; +@override@JsonKey() List get routes { + if (_routes is EqualUnmodifiableListView) return _routes; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_routes); +} + + +/// Create a copy of TailscaleProxy +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TailscaleProxyCopyWith<_TailscaleProxy> get copyWith => __$TailscaleProxyCopyWithImpl<_TailscaleProxy>(this, _$identity); + +@override +Map toJson() { + return _$TailscaleProxyToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TailscaleProxy&&(identical(other.name, name) || other.name == name)&&(identical(other.authKey, authKey) || other.authKey == authKey)&&(identical(other.hostname, hostname) || other.hostname == hostname)&&(identical(other.controlUrl, controlUrl) || other.controlUrl == controlUrl)&&(identical(other.stateDir, stateDir) || other.stateDir == stateDir)&&(identical(other.ephemeral, ephemeral) || other.ephemeral == ephemeral)&&(identical(other.udp, udp) || other.udp == udp)&&(identical(other.acceptRoutes, acceptRoutes) || other.acceptRoutes == acceptRoutes)&&(identical(other.exitNode, exitNode) || other.exitNode == exitNode)&&(identical(other.exitNodeAllowLanAccess, exitNodeAllowLanAccess) || other.exitNodeAllowLanAccess == exitNodeAllowLanAccess)&&const DeepCollectionEquality().equals(other._routes, _routes)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,authKey,hostname,controlUrl,stateDir,ephemeral,udp,acceptRoutes,exitNode,exitNodeAllowLanAccess,const DeepCollectionEquality().hash(_routes)); + +@override +String toString() { + return 'TailscaleProxy(name: $name, authKey: $authKey, hostname: $hostname, controlUrl: $controlUrl, stateDir: $stateDir, ephemeral: $ephemeral, udp: $udp, acceptRoutes: $acceptRoutes, exitNode: $exitNode, exitNodeAllowLanAccess: $exitNodeAllowLanAccess, routes: $routes)'; +} + + +} + +/// @nodoc +abstract mixin class _$TailscaleProxyCopyWith<$Res> implements $TailscaleProxyCopyWith<$Res> { + factory _$TailscaleProxyCopyWith(_TailscaleProxy value, $Res Function(_TailscaleProxy) _then) = __$TailscaleProxyCopyWithImpl; +@override @useResult +$Res call({ + String name, String authKey, String hostname, String controlUrl, String stateDir, bool ephemeral, bool udp, bool acceptRoutes, String exitNode, bool exitNodeAllowLanAccess, List routes +}); + + + + +} +/// @nodoc +class __$TailscaleProxyCopyWithImpl<$Res> + implements _$TailscaleProxyCopyWith<$Res> { + __$TailscaleProxyCopyWithImpl(this._self, this._then); + + final _TailscaleProxy _self; + final $Res Function(_TailscaleProxy) _then; + +/// Create a copy of TailscaleProxy +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? authKey = null,Object? hostname = null,Object? controlUrl = null,Object? stateDir = null,Object? ephemeral = null,Object? udp = null,Object? acceptRoutes = null,Object? exitNode = null,Object? exitNodeAllowLanAccess = null,Object? routes = null,}) { + return _then(_TailscaleProxy( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,authKey: null == authKey ? _self.authKey : authKey // ignore: cast_nullable_to_non_nullable +as String,hostname: null == hostname ? _self.hostname : hostname // ignore: cast_nullable_to_non_nullable +as String,controlUrl: null == controlUrl ? _self.controlUrl : controlUrl // ignore: cast_nullable_to_non_nullable +as String,stateDir: null == stateDir ? _self.stateDir : stateDir // ignore: cast_nullable_to_non_nullable +as String,ephemeral: null == ephemeral ? _self.ephemeral : ephemeral // ignore: cast_nullable_to_non_nullable +as bool,udp: null == udp ? _self.udp : udp // ignore: cast_nullable_to_non_nullable +as bool,acceptRoutes: null == acceptRoutes ? _self.acceptRoutes : acceptRoutes // ignore: cast_nullable_to_non_nullable +as bool,exitNode: null == exitNode ? _self.exitNode : exitNode // ignore: cast_nullable_to_non_nullable +as String,exitNodeAllowLanAccess: null == exitNodeAllowLanAccess ? _self.exitNodeAllowLanAccess : exitNodeAllowLanAccess // ignore: cast_nullable_to_non_nullable +as bool,routes: null == routes ? _self._routes : routes // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +// dart format on diff --git a/lib/models/generated/tailscale.g.dart b/lib/models/generated/tailscale.g.dart new file mode 100644 index 0000000000..cbb2e565bc --- /dev/null +++ b/lib/models/generated/tailscale.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../tailscale.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_TailscaleProps _$TailscalePropsFromJson(Map json) => + _TailscaleProps( + enable: json['enable'] as bool? ?? false, + bypassTraffic: json['bypassTraffic'] as bool? ?? false, + proxies: + (json['proxies'] as List?) + ?.map((e) => TailscaleProxy.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$TailscalePropsToJson(_TailscaleProps instance) => + { + 'enable': instance.enable, + 'bypassTraffic': instance.bypassTraffic, + 'proxies': instance.proxies, + }; + +_TailscaleProxy _$TailscaleProxyFromJson(Map json) => + _TailscaleProxy( + name: json['name'] as String, + authKey: json['authKey'] as String? ?? '', + hostname: json['hostname'] as String? ?? '', + controlUrl: json['controlUrl'] as String? ?? '', + stateDir: json['stateDir'] as String? ?? '', + ephemeral: json['ephemeral'] as bool? ?? false, + udp: json['udp'] as bool? ?? false, + acceptRoutes: json['acceptRoutes'] as bool? ?? false, + exitNode: json['exitNode'] as String? ?? '', + exitNodeAllowLanAccess: json['exitNodeAllowLanAccess'] as bool? ?? false, + routes: + (json['routes'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + ); + +Map _$TailscaleProxyToJson(_TailscaleProxy instance) => + { + 'name': instance.name, + 'authKey': instance.authKey, + 'hostname': instance.hostname, + 'controlUrl': instance.controlUrl, + 'stateDir': instance.stateDir, + 'ephemeral': instance.ephemeral, + 'udp': instance.udp, + 'acceptRoutes': instance.acceptRoutes, + 'exitNode': instance.exitNode, + 'exitNodeAllowLanAccess': instance.exitNodeAllowLanAccess, + 'routes': instance.routes, + }; diff --git a/lib/models/models.dart b/lib/models/models.dart index 78ab4e862f..ea8a27e5ad 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -2,6 +2,7 @@ export 'app.dart'; export 'clash_config.dart'; export 'common.dart'; export 'config.dart'; +export 'tailscale.dart'; export 'core.dart'; export 'profile.dart'; export 'state.dart'; diff --git a/lib/models/state.dart b/lib/models/state.dart index ef30099eef..2ddcc82413 100644 --- a/lib/models/state.dart +++ b/lib/models/state.dart @@ -7,6 +7,7 @@ import 'app.dart'; import 'clash_config.dart'; import 'common.dart'; import 'config.dart'; +import 'tailscale.dart'; import 'core.dart'; import 'profile.dart'; @@ -345,6 +346,9 @@ abstract class MakeRealProfileState with _$MakeRealProfileState { required List rules, required List addedRules, required String defaultUA, + @Default([]) List tailscaleProxies, + @Default([]) List tailscaleRules, + @Default([]) List tailscaleFakeIpFilters, }) = _MakeRealProfileState; } diff --git a/lib/models/tailscale.dart b/lib/models/tailscale.dart new file mode 100644 index 0000000000..8882d16211 --- /dev/null +++ b/lib/models/tailscale.dart @@ -0,0 +1,247 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'generated/tailscale.freezed.dart'; + +part 'generated/tailscale.g.dart'; + +/// The outbound `type` value understood by the mihomo core for Tailscale nodes. +const tailscaleProxyType = 'tailscale'; + +const defaultTailscaleProps = TailscaleProps(); + +/// User configurable Tailscale support. +/// +/// Tailscale is opt-in: [enable] gates whether the authored [proxies] are +/// injected into the running configuration. When disabled the generated config +/// is identical to the plain profile config, so normal traffic keeps flowing +/// globally just like before. +@freezed +abstract class TailscaleProps with _$TailscaleProps { + const factory TailscaleProps({ + @Default(false) bool enable, + @Default(false) bool bypassTraffic, + @Default([]) List proxies, + }) = _TailscaleProps; + + factory TailscaleProps.fromJson(Map json) => + _$TailscalePropsFromJson(json); + + factory TailscaleProps.safeFromJson(Map? json) { + if (json == null) { + return defaultTailscaleProps; + } + try { + return TailscaleProps.fromJson(json); + } catch (_) { + return defaultTailscaleProps; + } + } +} + +/// Domains that must leave FlClash's tunnel alone when the host also runs the +/// real Tailscale app/daemon (control plane, DERP, MagicDNS). +const tailscaleBypassDomains = [ + 'tailscale.com', + 'tailscale.io', + 'ts.net', +]; + +/// Clash `fake-ip-filter` entries so Tailscale domains resolve to real public +/// IPs instead of Clash's `198.18.0.0/16` fake-IP range. +/// +/// Without these, `controlplane.tailscale.com` is answered as something like +/// `198.18.0.12`, the TLS handshake to the control plane hangs mid-certificate, +/// and `tailscale up` never completes — even if DOMAIN-SUFFIX DIRECT rules are +/// present. Prefix `+.` matches the domain and all subdomains in mihomo. +const tailscaleFakeIpFilters = [ + '+.tailscale.com', + '+.tailscale.io', + '+.ts.net', +]; + +/// Rules that keep the host's own Tailscale traffic outside FlClash's tunnel. +/// +/// This is for the case where the same device *also* runs the real Tailscale +/// app/daemon (e.g. a home PC that must stay reachable from outside). Sending +/// the tailnet CGNAT ranges, the control/DERP domains and the `tailscaled` +/// process straight to `DIRECT` stops FlClash from hijacking that traffic, so +/// inbound Tailscale connections keep working regardless of which VPN provider +/// profile is loaded. Pair with [tailscaleFakeIpFilters] so DNS is not +/// poisoned by fake-IP either. +const tailscaleBypassRules = [ + 'IP-CIDR,100.64.0.0/10,DIRECT,no-resolve', + 'IP-CIDR6,fd7a:115c:a1e0::/48,DIRECT,no-resolve', + 'PROCESS-NAME,tailscaled,DIRECT', + 'PROCESS-NAME,tailscaled.exe,DIRECT', + 'PROCESS-NAME,tailscale,DIRECT', + 'PROCESS-NAME,tailscale.exe,DIRECT', + 'DOMAIN-SUFFIX,tailscale.com,DIRECT', + 'DOMAIN-SUFFIX,tailscale.io,DIRECT', + 'DOMAIN-SUFFIX,ts.net,DIRECT', +]; + +/// Builds a single clash rule that routes [dest] through [target]. +/// +/// The rule type is inferred from [dest]: a CIDR or bare IP becomes an +/// `IP-CIDR`/`IP-CIDR6` rule (bare IPs get a /32 or /128 mask and `no-resolve`), +/// anything else is treated as a domain via `DOMAIN-SUFFIX`. +String buildTailscaleRouteRule(String dest, String target) { + final value = dest.trim(); + final isV6 = value.contains(':'); + if (value.contains('/')) { + final type = isV6 ? 'IP-CIDR6' : 'IP-CIDR'; + return '$type,$value,$target,no-resolve'; + } + final isV4 = RegExp(r'^\d{1,3}(\.\d{1,3}){3}$').hasMatch(value); + if (isV4) { + return 'IP-CIDR,$value/32,$target,no-resolve'; + } + if (isV6) { + return 'IP-CIDR6,$value/128,$target,no-resolve'; + } + return 'DOMAIN-SUFFIX,$value,$target'; +} + +extension TailscalePropsExt on TailscaleProps { + /// The nodes that should actually be merged into the config. Empty when the + /// feature is switched off so Tailscale stops handling any traffic. + List get activeProxies => + enable ? proxies : const []; + + /// Clash rules that FlClash injects at the top of the running configuration. + /// + /// These are prepended (highest priority) so they win over whatever the + /// imported VPN provider profile does, which means the user does not have to + /// hand-edit rules for every profile. Two independent things are produced: + /// + /// * Per-node [TailscaleProxy.routes] -> a rule sending that destination + /// through the node (only when [enable] is on, since the outbound only + /// exists then). This is how a phone reaches the home host through + /// FlClash's built-in tailnet node without running the Tailscale app. + /// * [bypassTraffic] -> the [tailscaleBypassRules] so a device that also runs + /// the Tailscale service keeps that traffic direct. + /// + /// Route rules come first so a specific destination still wins over the broad + /// bypass range even if both options are enabled at once. + List buildInjectedRules() { + final rules = []; + if (enable) { + for (final proxy in proxies.where((item) => item.isValid)) { + for (final dest in proxy.routes) { + if (dest.trim().isEmpty) { + continue; + } + rules.add(buildTailscaleRouteRule(dest, proxy.name.trim())); + } + } + } + if (bypassTraffic) { + rules.addAll(tailscaleBypassRules); + } + return rules; + } + + /// Fake-IP filter entries injected when [bypassTraffic] is on. + /// + /// Empty when the toggle is off so DNS behaviour is unchanged. See + /// [tailscaleFakeIpFilters]. + List buildFakeIpFilters() => + bypassTraffic ? tailscaleFakeIpFilters : const []; +} + +/// A user authored Tailscale outbound node. +/// +/// The mihomo core (built with the `with_gvisor` tag and without +/// `no_tailscale`) already supports a `tailscale` outbound. FlClash only ever +/// received proxies from imported subscription YAML, so there was no way to add +/// a Tailscale node from the app. This model captures the fields the core +/// understands and can serialize them into a proxy map that is merged into the +/// generated config through [toOutboundJson]. +@freezed +abstract class TailscaleProxy with _$TailscaleProxy { + const factory TailscaleProxy({ + required String name, + @Default('') String authKey, + @Default('') String hostname, + @Default('') String controlUrl, + @Default('') String stateDir, + @Default(false) bool ephemeral, + @Default(false) bool udp, + @Default(false) bool acceptRoutes, + @Default('') String exitNode, + @Default(false) bool exitNodeAllowLanAccess, + @Default([]) List routes, + }) = _TailscaleProxy; + + factory TailscaleProxy.fromJson(Map json) => + _$TailscaleProxyFromJson(json); +} + +extension TailscaleProxyExt on TailscaleProxy { + /// Whether the node has the minimum data required to build a valid outbound. + bool get isValid => name.trim().isNotEmpty; + + /// Builds the mihomo `proxies` entry for this node. + /// + /// Only non empty optional values are emitted so the core keeps its own + /// defaults for anything left blank. Keys use the kebab-case names the core + /// parser expects (see `adapter/outbound/tailscale.go`). + Map toOutboundJson() { + final map = { + 'name': name.trim(), + 'type': tailscaleProxyType, + }; + if (authKey.trim().isNotEmpty) { + map['auth-key'] = authKey.trim(); + } + if (hostname.trim().isNotEmpty) { + map['hostname'] = hostname.trim(); + } + if (controlUrl.trim().isNotEmpty) { + map['control-url'] = controlUrl.trim(); + } + if (stateDir.trim().isNotEmpty) { + map['state-dir'] = stateDir.trim(); + } + if (ephemeral) { + map['ephemeral'] = true; + } + if (udp) { + map['udp'] = true; + } + if (acceptRoutes) { + map['accept-routes'] = true; + } + if (exitNode.trim().isNotEmpty) { + map['exit-node'] = exitNode.trim(); + map['exit-node-allow-lan-access'] = exitNodeAllowLanAccess; + } + return map; + } +} + +extension TailscaleProxyListExt on List { + /// Merges the valid Tailscale nodes in this list into a raw clash config + /// [rawConfig]'s `proxies` list. + /// + /// Nodes are matched by `name`; an existing proxy with the same name is + /// replaced so the app authored value always wins. The input map is not + /// mutated. Returns a new config map ready to be serialized to YAML. + Map mergeInto(Map rawConfig) { + final validProxies = where((item) => item.isValid).toList(); + if (validProxies.isEmpty) { + return Map.from(rawConfig); + } + final nextConfig = Map.from(rawConfig); + final existing = [ + ...?(nextConfig['proxies'] as List?), + ]; + final tailscaleNames = validProxies.map((item) => item.name.trim()).toSet(); + existing.removeWhere((item) { + return item is Map && tailscaleNames.contains(item['name']); + }); + existing.addAll(validProxies.map((item) => item.toOutboundJson())); + nextConfig['proxies'] = existing; + return nextConfig; + } +} diff --git a/lib/pages/home.dart b/lib/pages/home.dart index c5742a8742..7879ce44d1 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -49,21 +49,22 @@ class HomePage extends StatelessWidget { selectedIndex: currentIndex, ), ); - if (isMobile) { - return Column( - children: [ - Flexible( - flex: 1, - child: MediaQuery.removePadding( - removeTop: false, - removeBottom: true, - removeLeft: true, - removeRight: true, - context: context, - child: child!, - ), + return Column( + children: [ + Flexible( + flex: 1, + child: MediaQuery.removePadding( + removeTop: false, + removeBottom: isMobile, + removeLeft: isMobile, + removeRight: isMobile, + context: context, + child: child!, ), - MediaQuery.removePadding( + ), + AnimatedVisibility.bottomNavigation( + visible: isMobile, + child: MediaQuery.removePadding( removeTop: true, removeBottom: false, removeLeft: true, @@ -71,11 +72,9 @@ class HomePage extends StatelessWidget { context: context, child: bottomNavigationBar, ), - ], - ); - } else { - return child!; - } + ), + ], + ); }, child: Consumer( builder: (_, ref, _) { @@ -89,10 +88,14 @@ class HomePage extends StatelessWidget { final navigationItem = navigationItems[index]; final navigationView = navigationItem.builder(context); final view = KeepScope( + key: ValueKey(navigationItem.label), keep: navigationItem.keep, child: isMobile ? navigationView : Navigator( + key: ValueKey( + '${navigationItem.label.name}_navigator', + ), pages: [MaterialPage(child: navigationView)], onDidRemovePage: (_) {}, ), @@ -195,6 +198,15 @@ class _HomePageViewState extends ConsumerState<_HomePageView> { controller: _pageController, physics: const NeverScrollableScrollPhysics(), itemCount: itemCount, + findChildIndexCallback: (key) { + if (key is! ValueKey) { + return null; + } + final index = widget.navigationItems.indexWhere( + (item) => item.label == key.value, + ); + return index == -1 ? null : index; + }, itemBuilder: (context, index) { return widget.pageBuilder(context, index); }, 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..5a52f87026 --- /dev/null +++ b/lib/providers/actions/setup.dart @@ -0,0 +1,439 @@ +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 tailscaleProps = ref.read(tailscaleSettingProvider); + final tailscaleProxies = tailscaleProps.activeProxies; + final tailscaleRules = tailscaleProps.buildInjectedRules(); + final tailscaleFakeIpFilters = tailscaleProps.buildFakeIpFilters(); + 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, + tailscaleProxies: tailscaleProxies, + tailscaleRules: tailscaleRules, + tailscaleFakeIpFilters: tailscaleFakeIpFilters, + ), + ); + 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/config.dart b/lib/providers/config.dart index 00ad80c4d1..c3ebf9df08 100644 --- a/lib/providers/config.dart +++ b/lib/providers/config.dart @@ -105,6 +105,61 @@ class ExcludeSSIDs extends _$ExcludeSSIDs with AutoDisposeNotifierMixin { } } +@riverpod +class TailscaleSetting extends _$TailscaleSetting with AutoDisposeNotifierMixin { + @override + TailscaleProps build() { + return const TailscaleProps(); + } + + void setEnable(bool enable) { + update((state) => state.copyWith(enable: enable)); + } + + void setBypassTraffic(bool bypassTraffic) { + update((state) => state.copyWith(bypassTraffic: bypassTraffic)); + // Keep Config → DNS → Fake IP Filter in sync with the toggle so the user + // can see the entries appear/disappear, and so override-DNS mode also + // picks them up without a separate hand edit. + ref.read(patchClashConfigProvider.notifier).update((state) { + final filters = List.from(state.dns.fakeIpFilter); + if (bypassTraffic) { + for (final filter in tailscaleFakeIpFilters) { + if (!filters.contains(filter)) { + filters.add(filter); + } + } + } else { + filters.removeWhere(tailscaleFakeIpFilters.contains); + } + return state.copyWith.dns(fakeIpFilter: filters); + }); + } + + void addOrUpdate(TailscaleProxy proxy, {String? previousName}) { + update((state) { + final next = List.from(state.proxies); + final lookupName = previousName ?? proxy.name; + final index = next.indexWhere((item) => item.name == lookupName); + if (index == -1) { + next.add(proxy); + } else { + next[index] = proxy; + } + return state.copyWith(proxies: next); + }); + } + + void remove(String name) { + update((state) { + return state.copyWith( + proxies: state.proxies.where((item) => item.name != name).toList(), + ); + }); + } +} + + @Riverpod(name: 'configProvider') Config _config(Ref ref) { final appSettingProps = ref.watch(appSettingProvider); @@ -119,6 +174,7 @@ Config _config(Ref ref) { final proxiesStyleProps = ref.watch(proxiesStyleSettingProvider); final patchClashConfig = ref.watch(patchClashConfigProvider); final excludeSSIDs = ref.watch(excludeSSIDsProvider); + final tailscaleProps = ref.watch(tailscaleSettingProvider); return Config( appSettingProps: appSettingProps, windowProps: windowProps, @@ -132,6 +188,7 @@ Config _config(Ref ref) { proxiesStyleProps: proxiesStyleProps, patchClashConfig: patchClashConfig, excludeSSIDs: excludeSSIDs, + tailscaleProps: tailscaleProps, ); } @@ -155,5 +212,8 @@ List buildConfigOverrides(Config config) { (_, _) => config.patchClashConfig, ), excludeSSIDsProvider.overrideWithBuild((_, _) => config.excludeSSIDs), + tailscaleSettingProvider.overrideWithBuild( + (_, _) => config.tailscaleProps, + ), ]; } 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..21d602754d 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'0bff03f323d82075b2fb7481e24b10ed08771eee'; 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..ee2671cf1b 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,59 @@ abstract class _$ExcludeSSIDs extends $Notifier> { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); + } +} + +@ProviderFor(TailscaleSetting) +final tailscaleSettingProvider = TailscaleSettingProvider._(); + +final class TailscaleSettingProvider + extends $NotifierProvider { + TailscaleSettingProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'tailscaleSettingProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tailscaleSettingHash(); + + @$internal + @override + TailscaleSetting create() => TailscaleSetting(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(TailscaleProps value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$tailscaleSettingHash() => r'0671fd8714eec5eafdf6f51ffae54abe45b66e69'; + +abstract class _$TailscaleSetting extends $Notifier { + TailscaleProps build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + TailscaleProps, + Object?, + Object? + >; + return element.handleCreate(ref, build); } } @@ -670,4 +722,4 @@ final class _ConfigProvider extends $FunctionalProvider } } -String _$_configHash() => r'7f29da1e31a3393fb36ab43c21f0d1b38223afec'; +String _$_configHash() => r'2151b4807c98ef67c0c0d122e0dcd0e9394e65c7'; 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