diff --git a/.agents/architecture.md b/.agents/architecture.md index fdbd6dadd5..35c2d034c8 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -157,6 +157,20 @@ Proxy delay testing follows the same failure-safe UI rule. `proxyDelayTest()` re real result on success, and logs plus records `-1` on exceptions. `DelayTestButton` reverses its animation in `finally`, so an RPC failure cannot leave the control permanently spinning. +## Settings Rows + +`lib/widgets/config_item.dart` holds the shared settings-row vocabulary: `ConfigToggleItem`, `ConfigOptionsItem`, +`ConfigTextItem`, and `ConfigListInputItem`. Each takes a `selector` (a `ProviderListenable`, normally +`someProvider.select(...)`) and an `onChanged(ref, value)` writer, and watches its own selector so changing one setting +rebuilds one row instead of the whole section. Titles and subtitles are `ConfigLabel` callbacks that receive +`AppLocalizations`, which keeps literal labels such as `IPv6` and localized labels in the same shape. + +Build settings screens from these directly, or from a file-local helper that binds one provider once — see `_dnsToggle` +in `lib/views/config/dns.dart` and `_appSettingToggle` in `lib/views/application_setting.dart`. Declare a named +`ConsumerWidget` only when a row is genuinely reused across screens, as `lib/views/config/network.dart` rows are by +`lib/views/dashboard/widgets/quick_options.dart`. Rows with bespoke behaviour — a custom dialog, a derived value, or a +second provider write — stay hand-written rather than growing extra parameters on the shared items. + ## State Management Provider files in `lib/providers/`: @@ -164,10 +178,155 @@ Provider files in `lib/providers/`: - `app.dart`: runtime/UI state, logs, traffic, delays, loading, navigation. - `config.dart`: persistent config providers, app settings, theme, VPN, proxy style. - `state.dart`: derived/computed providers, navigation, proxy, tray, color scheme. + Like `action.dart`, this is an entry point only: the providers live under + `lib/providers/state/` and are joined with `part` directives, so importing + `state.dart` still reaches all of them. + - `state/proxies.dart`: group and proxy lists, filter/sort, delay, selection. + - `state/navigation.dart`: navigation items, current page, dashboard, more tools. + - `state/system.dart`: tray, VPN params, access control, hot keys, shared state. + - `state/theme.dart`: dynamic color, color scheme, brightness. + - `state/profile.dart`: profiles, current profile, clash config, setup state. + - `state/overwrite.dart`: custom overwrite validity and the staged group/rule notifiers. - `action.dart`: business logic notifiers, setup, backup, core lifecycle, proxy selection. +- `core.dart`: `coreHandlerProvider`, the container-scoped handle on `CoreController`. - `database.dart`: Drift database provider wrappers. -`globalState` in `lib/state.dart` is a singleton holding app lifecycle, timers, theme, and start/stop state. Providers are generated into `lib/providers/generated/`. +### Reaching Singletons + +`lib/common/` and `lib/core/` publish process-wide singletons (`coreController`, +`system`, `preferences`, `appPath`, `request`, and others). Code that already has a +`Ref` or a `WidgetRef` reads them through a provider instead, so a test can scope a +fake to one `ProviderContainer` rather than swapping a global and relying on a +tearDown to put it back. + +`coreHandlerProvider` is the established case. Every call site under +`lib/providers/`, `lib/manager/` and `lib/views/` goes through it; notifiers and +`ConsumerState` classes that touch Core repeatedly hold it as +`CoreController get _core => ref.read(coreHandlerProvider)`. + +Tests override it with `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))`, +which does not claim the singleton. `CoreController.test` does claim it, and is +only for tests that have not moved yet. Prefer the scoped override even when a +test passes either way: a test that claims the singleton makes the global and the +provider resolve to the same fake, so it cannot tell a provider read from a +leftover global read, and a half-migrated call site stays green. + +One deliberate exception: `globalState` owns the `ProviderContainer`, so it cannot +itself live in one. Code without a `Ref` reaches providers through +`globalState.container.read(...)`. The remaining call sites are `lib/core/lib.dart`, +`lib/common/print.dart`, and the tray `read` callback in +`lib/providers/actions/system.dart` — all singletons or platform callbacks with no +`Ref` in scope. + +`lib/models/profile.dart` no longer reaches Core. `Profile.saveFile` and +`Profile.update` take a `ValidateConfig` callback, and every caller passes +`(path) => _core.validateConfig(path)`, keeping the Core handle lazy so a profile +path that never validates never resolves the controller. + +The UI layer must not reach a process-wide singleton directly. +`test/lint/ui_layer_singleton_test.dart` scans `lib/views`, `lib/widgets`, +`lib/pages` and `lib/features` for `globalState.container` and the bare +`coreController` global and fails the run on either. Widgets that need Core hold +`CoreController get _core => ref.read(coreHandlerProvider)`. + +`globalState.measure` and `globalState.theme` stay global on purpose. Both are +context-derived caches assigned by `ThemeManager`, and tests already scope them by +assigning in the app builder (see `test/helpers/test_app.dart`); moving them into +providers would touch every layout call site without changing behaviour. + +`globalState` in `lib/state.dart` is a singleton holding ambient app state — the +package info, the measure and theme, the container, and the start/stop flags — +plus `safeRun`/`loadingRun`. Startup orchestration is **not** on it: `init` and +`attach` live in `lib/bootstrap.dart`, above `lib/common`, because they drive the +window, the autostart entry, the tray and the permission prompts. Providers are +generated into `lib/providers/generated/`. + +The root navigator key lives in `lib/common/navigator.dart` as `rootNavigatorKey`; +`globalState.navigatorKey` is a getter onto it. `lib/common/dialog.dart` reaches +the key directly, so the dialog helpers no longer import `lib/state.dart`. + +### Platform Layering + +`lib/common/common.dart` deliberately does not export `tray.dart`, `window.dart`, +`launch.dart`, `system_dns.dart`, or `permission.dart`. Those five modules import +`tray_manager`, `window_manager`, `launch_at_startup`, and `screen_retriever`; +exporting them put those packages in the compile graph of all 132 files that +import the barrel for a string helper. Import the specific module instead. + +`test/lint/platform_layering_test.dart` enforces four rules. Three are local: the +barrel never re-exports one of those five modules, nothing under `lib/common`, +`lib/enum` or `lib/models` other than those five imports a desktop platform +package, and `lib/common` never imports the `lib/manager/manager.dart` barrel +(import the single manager needed, as `common/context.dart` does with +`manager/status_manager.dart`). The fourth walks the barrel's whole transitive +closure and fails if *any* file in it imports one of those packages. That one is +the real invariant — the local rules only stop the shortest path, and every leak +found so far arrived through a longer one. + +Four consequences are already in the tree: + +- `System.back` and `System.exit` no longer touch `window`; the window half of + both lives in `SystemAction`. +- `KeyboardModifier.toHotKeyModifier()` moved from `lib/enum/enum.dart` to + `lib/manager/hotkey_manager.dart`, its only consumer. +- Startup orchestration moved off `GlobalState` into `lib/bootstrap.dart`. + `common/num.dart`, `common/print.dart` and `common/request.dart` import + `state.dart` for `theme`, `container` and `packageInfo`/`ua`, so anything + `GlobalState` reaches lands in the barrel's closure; the ambient state it now + holds reaches nothing platform-specific. +- `SystemAction` talks to `WindowPort` and `TrayPort` from + `lib/common/app_ports.dart` instead of importing `common/window.dart` and + `common/tray.dart`. `lib/bootstrap.dart` binds `windowPort` and `trayPort` to + the real implementations; both stay null in tests, where every call through + them is a no-op. A test that needs the real tray assigns `trayPort` itself, as + `test/common/tray_menu_test.dart` does. + +Narrow the barrel imports too: `lib/providers/providers.dart` re-exports +`action.dart`, so importing the providers barrel from `lib/common` or from +`providers/app.dart` reaches the whole action layer. Those three now import +`providers/state.dart` and `providers/config.dart` directly. + +The same shape appeared twice more, without a platform package involved: a data +type in a lower layer holding the widget that renders it, which drags the whole +view tree into the barrel's closure. + +- `lib/common/navigation.dart` was a route table building view widgets. It is + now `lib/views/navigation.dart` implementing `NavigationPort`, which + `providers/state/navigation.dart` reads through and `bootstrap.init` binds. + Unbound it yields no items, so a test that renders navigation assigns + `navigationPort` itself, as `test/pages/home_test.dart` does. +- `DashboardWidget` carried a `GridItem` per value, so `lib/enum/enum.dart` + imported the dashboard cards — and `lib/widgets/widgets.dart` with them. The + enum is persisted in the app settings, so it is back to plain data; the + mapping lives in `lib/views/dashboard/widget_registry.dart`, the reverse + lookup relying on the branches returning canonical consts. + +Together those took the closure from 258 files to 187, with nothing under +`lib/views` left in it. `test/lint/platform_layering_test.dart` pins that +directly: the barrel's closure must contain no `lib/views` file. Data the +provider layer needs from the UI layer goes through a port in +`lib/common/app_ports.dart` rather than an import in the other direction. + +### High-Frequency Buffers + +`logsProvider`, `requestsProvider` and `trafficsProvider` hold a `FixedList` +(`lib/common/fixed.dart`), which trades a normal copy-on-write for a shared +buffer tagged with a generation counter: + +- `append` mutates the buffer in place and returns a new wrapper one generation + ahead. That is what providers publish, so `updateShouldNotify` still fires. +- `list` returns an immutable copy, cached until the next mutation. It must stay + eager: an older wrapper shares the buffer, so its contents move on. Anything + that needs a stable view has to read `list` at the moment it is notified, not + hold the wrapper and read later. +- Consumers that only need to know *that* the buffer changed watch `revision`, + not `list` — selecting on the list snapshots and deep-compares the whole + buffer on every arrival, which is what this design exists to avoid. See + `lib/views/logs.dart` for the pattern: watch the generation, snapshot inside + the throttled callback. + +`add`/`clear` mutate in place without advancing the generation; use them only on +a buffer you own outright (seeding, resets, tests), never on published state. ## Database @@ -188,16 +347,31 @@ Generated Drift output lives in `lib/database/generated/database.g.dart`. After ## Manager Stack -Managers are nested `InheritedWidget`/`StatefulWidget` components in `lib/application.dart`: +Managers are nested `InheritedWidget`/`StatefulWidget` components built by `buildManagerStack()` in `lib/application.dart`: ```text AppEnvManager > StatusManager > ThemeManager > [Desktop: WindowManager > TrayManager > HotKeyManager > ProxyManager] - > ConnectivityManager > CoreManager > AppStateManager - > [Mobile: AndroidManager > VpnManager | Desktop: WindowHeaderContainer] + [Mobile: AndroidManager > TileManager] + > AppStateManager > CoreManager > ConnectivityManager + > [Desktop: WindowHeaderContainer] [Mobile: VpnManager] + > app content ``` -Each manager in `lib/manager/` handles a specific platform concern. Desktop-only managers are conditionally inserted. +Each manager in `lib/manager/` handles a specific platform concern. The +platform slots are exclusive: no desktop manager appears on mobile and no mobile +manager appears on desktop. + +The order is an ownership contract, not a layout detail. `ConnectivityManager` +sits below `CoreManager` because its `onConnectivityChanged` callback reads +Core-backed state, so Core must already be mounted when it fires. `StatusManager` +and `ThemeManager` sit above the platform managers so a platform manager can +surface a message or read the theme. + +`buildManagerStack()` is a pure function of `isDesktop`, the connectivity +callback, and the app content, so `test/application_test.dart` asserts the whole +order by constructing the stack without mounting it. Changing the nesting means +updating both this diagram and that test. ## Core Controller and Actions @@ -218,6 +392,17 @@ directives, so consumers continue to import the same public API: - `ProxiesAction`: group management and proxy selection. - `ProfilesAction`: profile CRUD, auto-update, import. - `GeoResourceAction`: geo resource updates and URL configuration. +- `UpdatingAction`: stale sweep over `UpdatingKeys`. + +`UpdatingKeys` in `lib/providers/app.dart` owns every per-entity updating flag; `isUpdatingProvider(key)` is the +read-only view widgets watch. Callers pair `start(key, scope: ...)` with `stop(key, operation)` using the returned +token, so overlapping operations on one key are reference counted and a late `stop` from a superseded operation +cannot clear a newer one. Keys started with `UpdatingScope.core` depend on the Core to make progress, so +`UpdatingKeys` discards them itself when `coreStatusProvider` leaves `connected` — that is the state's own +invariant, not a policy, and it must stay inside the notifier where no warm-up ordering can miss it. +`UpdatingScope.local` keys (profile updates run entirely in Dart) survive a Core restart. `UpdatingAction` holds +only the policy half: the periodic sweep that discards a key stuck past `updatingStaleTimeout`. Do not move the +timeout back into `UpdatingKeys`, and do not widen the disconnect reset to every scope. ## Platform Managers @@ -329,6 +514,10 @@ Windows helper integrity/version check: - `/stop` requires the same session ID. A missing process returns `notRunning`; a different owner returns `sessionMismatch` without terminating that process. Session IDs are ownership tokens for lifecycle safety, not a claim that the loopback HTTP endpoints are authenticated. +- Never take `MANAGED_CORE` or `LOGS` with `lock().unwrap()`. The Helper is a long-lived service running as SYSTEM, so a + single panic while a lock is held would poison it and turn every later request into another panic — the service stays + dead until Windows restarts it. `lock_surviving_poison` recovers the guard through `PoisonError::into_inner` instead. + `hub.rs` uses it at every lock site, tests included, and two tests in that file pin the behaviour. Build configuration defaults live in `build_tool/lib/src/options.dart` and can be overridden via a root `build_config.yaml`. @@ -339,9 +528,8 @@ Architecture detection is automatic. The `--description` flag passed to `flutter - `setup`: build-time harness for Go core artifacts and the Windows Rust helper; no runtime Dart API. - `proxy`: system proxy configuration. - `rust_api`: runtime Flutter Rust Bridge FFI plugin built through Cargokit. -- `tray_manager`: system tray fork/customization. +- `tray`: system tray for Linux, macOS and Windows. Written for FlClash; replaced the `tray_manager` fork. - `wifi_ssid`: Wi-Fi SSID detection. -- `window_ext`: window extensions. - `flutter_distributor`: app packaging/distribution. ## Rust Helper Service diff --git a/.agents/commands.md b/.agents/commands.md index 9d9154799d..b61d0a1d91 100644 --- a/.agents/commands.md +++ b/.agents/commands.md @@ -87,7 +87,7 @@ flutter test test/setup_test.dart flutter test plugins/proxy/test/proxy_test.dart ``` -Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory. Native plugin tests under platform folders are not run by `flutter test`. +Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`. For the current Core/service architecture, useful focused checks are: @@ -142,6 +142,66 @@ JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradl Always-on VPN entry, system VPN revoke, actual permission UI, and rapid device start/stop still require Android device or emulator validation; Kotlin compilation cannot prove those system callbacks. +## Changelog And Release + +The changelog is derived from Conventional Commits by `tool/changelog.dart` and written to two committed files: +`CHANGELOG.md` for readers and `changelog.json` for the renderers. See `.agents/rules.md` for the `Changelog:` trailers +that decide the wording. + +The app ships no changelog of its own. `render release` appends the released version as JSON inside an HTML comment +(``), so the release body GitHub already returns to `checkForUpdate` carries the +notes shown in the update dialog. `parseReleaseChangelog` reads that block and falls back to the English +bullets when a release predates it. + +```bash +dart run tool/changelog.dart verify # what CI checks +dart run tool/changelog.dart release --version 0.8.96 +dart run tool/changelog.dart build --unreleased # changelog.json only, includes untagged work +dart run tool/changelog.dart render release --out release.md +dart run tool/changelog.dart render telegram --out telegram.md +``` + +Releasing a stable version, in order: + +```bash +tool/bump_version.sh all +dart run tool/changelog.dart release --version 0.8.96 +git commit -am "chore(release): v0.8.96" +git tag v0.8.96 +git push origin main && git push origin v0.8.96 +``` + +Push the tag by name. Every release tag here is lightweight, and `--follow-tags` carries annotated tags only: it skips a +lightweight one silently, so the branch lands, the tag does not, and the release workflow never fires. + +`tool/release.sh` drives both paths so the ordering below cannot be got wrong by hand. It resolves the version (bumping +the patch when pubspec still names an already tagged one), prints the notes the tag would ship, and only pushes with +`--push`: + +```bash +tool/release.sh pre --dry-run # plan and notes, changes nothing +tool/release.sh pre --push # bump, tag vX.Y.Z-pre.N, push +tool/release.sh stable --push # changelog, chore(release) commit, tag, push +``` + +The release commit comes before the tag on purpose: the generated wording is reviewable in the diff before it ships, and +the tag is what `render release` reads. CI never writes back to the repository; it only runs `verify`. Wording in +`changelog.json` may be edited by hand as long as no derivable entry disappears and every entry still points at a commit +inside that version's range. + +Entries at or below `v0.8.95` are frozen: they predate the pipeline, live under the `` marker +in `CHANGELOG.md`, and are never regenerated. + +`verify` compares a version only when its tag is reachable from `HEAD`, because that is the same scope the builder walks +(`git tag --merged`). A branch cut before the newest release cannot derive that version at all, so `verify` names it as +skipped and moves on instead of reporting drift that does not exist. Checking mere tag existence is what made every such +branch fail on an unrelated release. + +Prerelease tags (`v0.8.96-pre.N`) skip the release commit, and CI renders their notes with `build --unreleased` for the +Telegram post. They publish no GitHub release, so the update dialog never sees them. `build --unreleased` reads the +version from `pubspec.yaml` rather than the tag, so the patch has to be bumped before the first `-pre.N` of a cycle: +while `v` is still tagged it refuses to collect anything and the release job fails. + ## Verify The tag-triggered release workflow runs these root-package checks in order: @@ -149,7 +209,9 @@ The tag-triggered release workflow runs these root-package checks in order: ```bash flutter pub get flutter analyze --no-fatal-infos +dart run tool/changelog.dart verify flutter test --reporter expanded +bash tool/check_commit_msg_test.sh ``` Run `flutter analyze` locally before committing when practical. @@ -160,3 +222,9 @@ plugin packages, so CI also validates local Flutter packages, the setup build tool, the Go wrapper, and Rust components from their own package directories. A separate Windows runner compiles and tests the helper's `windows-service` feature before release builds can start. + +`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script. +It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs +`flutter test` wherever `test/*_test.dart` exists. Adding a plugin package needs +no workflow edit; enumerating packages by hand in the workflow is what +previously left `plugins/tray` unanalyzed and untested. diff --git a/.agents/project.md b/.agents/project.md index dd63b87b7f..0e76673be4 100644 --- a/.agents/project.md +++ b/.agents/project.md @@ -4,9 +4,122 @@ FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with ## Version Notes -- Release CI pins Flutter 3.44.4. Local SDK may diverge, so trust the CI +- Release CI pins Flutter 3.47.1. Local SDK may diverge, so trust the CI version as the source of truth for release builds. -- Dart SDK constraint: `>=3.8.0 <4.0.0`. +- Dart SDK constraint: `>=3.8.0 <4.0.0`. The lower bound is load-bearing and + must not be raised to the Dart version the SDK actually ships; see + Dependency Ceilings. + +## Forked Dependencies + +Three `pubspec.yaml` dependencies are pinned to a fork — `window_manager` by tag, +the other two by commit SHA. All three +forks live under `chen08209`, the same account that owns this repository, so they +are maintained in-house rather than tracked from a third party: advancing a pin +is a local decision, and there is no external maintainer to wait on for the patch +itself. What each fork still waits on is the *upstream* fix that would let the +pin be dropped entirely, recorded below. + +Each entry records what the fork changes and what has to be true before it can go +back to the published package, so a future upgrade does not have to rediscover it. +Re-verify a fork by diffing its pub cache checkout against the published version +of the same number: + +```bash +diff -ru ~/.pub-cache/hosted/pub.dev/- ~/.pub-cache/git/- +``` + +`window_manager` — `chen08209/window_manager`, path `packages/window_manager`, +version 0.5.1, pinned to the tag `v0.5.1-flclash.1` because the fork carries +commits of its own rather than a single patch on top of a release. + +- `windows/window_manager_plugin.cpp`: with `titleBarStyle: hidden` a maximized + window uses the monitor work area (`GetMonitorInfo().rcWork`) instead of + upstream's `adjustNCCALCSIZE` border fudge, so it no longer covers the taskbar. +- `linux/window_manager_plugin.cc`: GTK drops the placement of an unmapped + window, so `hide` saves the geometry and the `map-event` handler applies it + again. Upstream only moves the window while it is hidden, which a window + manager is free to ignore — the window then reappears wherever it decides to + place it, which on this repository's Linux runner is every appearance after the + first, because `my_application.cc` never shows the toplevel itself. +- Adds `setWindowCornerPreference` (Windows) and `handleShouldTerminate` / + `onWindowShouldTerminate` (macOS). These lived in a local `window_ext` plugin + until they moved here; `lib/manager/window_manager.dart` and + `macos/Runner/AppDelegate.swift` are the callers. +- Drop the fork once upstream carries all three. The added APIs have call sites, + so this is not a pin change alone. + +`launch_at_startup` — `chen08209/launch_at_startup`, version 0.5.1. + +- Migrates `win32_registry` from `^2.0.0` to `^3.0.3`, which is a breaking rename + across the whole Windows implementation (`Registry.openPath` → `CURRENT_USER.open`, + `createValue` → `setValue`, `getStringValue` → `getString`). +- This one is not optional while it lasts: FlClash depends on `win32_registry: ^3.0.3` + directly, and upstream's `^2.0.0` constraint cannot co-resolve with it. +- Drop the fork when upstream publishes a release that accepts `win32_registry` 3.x. + +`yaml_writer` — `chen08209/yaml_writer`, version 2.1.0. + +- Adds `StringNode.quoteKey()` and applies it to map keys in `lib/src/node.dart`. + Upstream quotes values but emits keys verbatim, so a profile key needing quotes + is written as invalid YAML. +- Drop the fork once upstream quotes map keys by the same + `isValidUnquotedString` rule it already applies to values. + +## Dependency Ceilings + +Several dependencies cannot be advanced from this repository, and re-running +`flutter pub outdated` will keep listing them. The blocker is upstream in every +case, so treat the list as resolved-until-the-ceiling-moves rather than as debt: + +`freezed` is pinned exactly to `3.2.6-dev.1`, which is a pre-release *ahead* of +the newest stable `3.2.5`. It is not a stale pin and must not be "fixed" by +moving to `3.2.5`: stable `3.2.5` requires `analyzer >=9.0.0 <11.0.0`, while the +pinned Flutter SDK resolves `analyzer` 12. `3.2.6-dev.1` is the only published +freezed release that accepts `analyzer` 12. Move to a stable release only once +one exists that accepts the analyzer the SDK actually resolves. + +The `>=3.8.0` Dart lower bound is a language-version floor, not a stale minimum. +Dart 3.13 makes `final` on a parameter an error, and `freezed` still emits it in +the constructors it generates for every collection field it backs with a private +field (`const _LogsState({final List logs = const [], ...})`); 4.0.0-dev.3 +emits it too. A pubspec's lower bound sets the package language version, so +`>=3.8.0` keeps that generated code legal while the SDK itself runs 3.13. Raising +the bound makes every `*.freezed.dart` fail to parse, which `flutter analyze` +does not catch because `lib/**/generated/**` is excluded. Raise it only once +freezed stops emitting the modifier. + +One `analyzer` ceiling holds most of the remaining `flutter pub outdated` list. +The newest `build_runner`, `drift_dev`, `intl_utils`, `test`, and +`riverpod_generator` all require `analyzer` 13; `test` 1.31.2 additionally +requires `test_api` 0.7.13, while `flutter_test` from the pinned SDK depends on +`test_api` 0.7.12. Raising any of those bounds therefore fails version solving. + +The `riverpod` chain is the visible symptom. It is held at +`riverpod`/`flutter_riverpod` 3.3.2, `riverpod_annotation` 4.0.3, and +`riverpod_generator` 4.0.4 as one rigid unit, because `riverpod_annotation` +4.0.3 depends on `riverpod` exactly 3.3.2: the runtime cannot move without the +generator, and the generator cannot move without `analyzer` 13. Retry the whole +set after a Flutter SDK bump raises `test_api`, not before. + +`intl` is intentionally unbounded (`any`) and `material_color_utilities` is +resolved by the SDK; neither is a bound this repository sets. + +`isolate_contactor` is discontinued and `isolate_manager` is several majors +behind. Both arrive through `re_editor`, which pins `isolate_manager: ^4.1.5+1` +and is already at its own latest release. Nothing in this repository can advance +them. + +`CorePalette` is deprecated in `material_color_utilities` in favour of +`DynamicScheme`/`CorePalettes`, but `dynamic_color` 1.9.0 — its newest release — +still exposes only `DynamicColorPlugin.getCorePalette()`, which returns the +deprecated type. There is no migration available from this repository short of +calling the `io.material.plugins/dynamic_color` method channel directly and +decoding the palette int list by hand, which is not worth owning. The deprecated +type is therefore confined to `GlobalState._initDynamicColor`, which converts it +to the two plain seed colours the app actually consumes; everything downstream +sees `DynamicColorSeeds`. Revisit when `dynamic_color` ships a non-deprecated +accessor — only that one function has to change. ## Build Dependencies diff --git a/.agents/rules.md b/.agents/rules.md index abf4935b9c..779ea149a0 100644 --- a/.agents/rules.md +++ b/.agents/rules.md @@ -4,7 +4,11 @@ These are repository coding and testing conventions. Codex command permission ru ## Dart and Flutter Style -`analysis_options.yaml` enforces these non-default rules: +The lint set lives in `lint_options.yaml` at the repo root. The root `analysis_options.yaml` and every local plugin under +`plugins/*` include it, so application and plugin code are held to the same rules. Add or change a rule there, not in an +individual `analysis_options.yaml`; those files carry only their own `analyzer.exclude` entries. + +`lint_options.yaml` enforces these non-default rules: - `prefer_single_quotes: true`: always use single quotes. - `require_trailing_commas: true`: use trailing commas in multi-line argument lists. @@ -13,6 +17,38 @@ These are repository coding and testing conventions. Codex command permission ru - `prefer_const_constructors: true` and `prefer_const_declarations: true`. - `prefer_final_locals: true` and `prefer_final_in_for_each: true`. - `always_declare_return_types: true`. +- `only_throw_errors: true`: throw an `Exception` or `Error`, never a bare `String`. + +Failures whose whole content is a message meant for the user throw +`MessageException` from `lib/common/exception.dart`. Its `toString()` is the bare +message, which is what `globalState.safeRun` surfaces in the dialog, so the +user-facing text is unchanged from the older `throw someMessage` idiom while the +throw stays catchable as an `Exception` and carries a stack trace. Assert on it +with `isA().having((e) => e.message, 'message', ...)`, not on a +raw string. + +### Corner Radius + +Rounded corners are superellipses everywhere, not circular arcs. Use the superellipse API at each layer: + +- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`. +- Clips: `ClipRSuperellipse` instead of `ClipRRect`. +- Container decorations: `ShapeDecoration(shape: RoundedSuperellipseBorder(...))` instead of + `BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with + `clipBehavior` still clips to the shape path. +- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`. + +Passing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only +carries the corner magnitude; the rendered geometry stays a superellipse. + +APIs that accept only `BorderRadius` keep circular corners, with the superellipse supplied by an +enclosing clip or shape where one is needed: `InkWell.borderRadius`, `OutlineInputBorder`, +`ScrollbarThemeData.radius`, and `smooth_sheets`' `MaterialSheetDecoration`. Fully round pills +(`BorderRadius.circular(999)` or half the shortest side) may stay circular — both geometries coincide +there. + +CI gates formatting: `dart format --output=none --set-exit-if-changed lib test +tool plugins setup.dart` runs before `flutter analyze`. Generated directories are excluded from analysis: @@ -52,7 +88,7 @@ Pick the destination by where the constraint would be violated, not by how impor that. - **A fact that is true only at one call site, and is not visible from that call site, stays a comment there.** Its value is being in the reader's line of sight at the moment of the edit. `lib/common/constant.dart` is the model case: - the delay-test concurrency cap is bound to `mBatch` in `core/common.go`, and whoever changes that number must see the + the delay-test concurrency cap is bound to `delayTestConcurrency` in `core/common.go`, and whoever changes that number must see the constraint on the same screen. Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line; @@ -63,15 +99,109 @@ the invariant hard to break beats prose that asks the next reader not to break i ## Core API Safety +- `core/Clash.Meta` is a fork of mihomo, and changes to it are budgeted for features, not repairs. Fixing a bug there is + low priority even when the bug is real and the fix is small: every patch is one more thing to carry across an upstream + rebase. Solve it on the FlClash side of the boundary and note the mihomo behaviour you are working around. Reach into + the submodule only for a feature that has nowhere else to live, or when the problem is one the FlClash patches + themselves introduced — and say which of the two it is in the commit message. - Do not expose direct filesystem deletion APIs through Core or helper IPC; use a scope-specific cleanup API instead. - Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, JNI, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches. -- Keep high-volume log/request events separate from state-bearing events in `core/message.go`; bulk backpressure must not - evict delay, loaded-provider, or geo-update state. +- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update), delay, and + bulk (log, request). Delay and bulk evict their own oldest entry under backpressure; state uses `enqueueState`, which + never evicts, because a dropped `geoUpdate{updating:false}` leaves `isUpdatingProvider` stuck at true in the UI until + `UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full + queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports, + and reporting it from the message layer feeds the same batcher. +- `jni_get_string` in `android/core/src/main/cpp/jni_helper.cpp` `malloc`s and hands ownership to Go, which frees through + `free_string_func`. `quickSetup` relies on that: it reads its `*C.char` arguments inside a goroutine, after the JNI + wrapper has already returned. Switching the wrapper to `GetStringUTFChars`/`ReleaseStringUTFChars`, or freeing on the + C side, turns that read into a use-after-free. +- Go goroutines reach Java through `ATTACH_JNI()`, which attaches once with `AttachCurrentThreadAsDaemon` and detaches + from a `pthread_key` destructor at thread death. Do not restore a detach-per-call: `protect` runs once per outbound + socket and `onResult` once per event batch, and attach/detach takes ART's thread-list lock each time. +- Every JNI call into Kotlin must be followed by `jni_clear_exception`. A pending exception left in place aborts the + process on the next JNI call on that thread, so a throw in `protect`/`resolverProcess`/`onResult` becomes a crash in + unrelated code. +- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to + the log subscriber, batched, and handed back to `send`, so a send failure reported that way feeds itself; use + `logDeliveryError`, which writes to stderr and latches until a frame gets through or the next connection is installed. + A write that fails without putting a byte on the wire — host backpressure hitting `ipcWriteTimeout`, or a payload above + `maxIPCFrameSize` — drops that one frame and keeps the connection: the stream is still framed correctly, and tearing it + down here ends the read loop, and with it the Core process. Only a half-written frame desynchronizes the stream, and + that is the one case `send` closes on. +- Core method handlers in `core/hub.go` are synchronous. Anything that must not block the dispatcher is spawned by + `safeGo`/`safeGoDetached` in `core/method.go`, which recover; a bare `go` in a handler puts a panic outside every + recovery and kills the process, which on Android is the whole application. The `//export` entry points in + `core/lib.go` do not reach `handleMethodCall`, so each one carries its own recovery. +- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in + `core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the + socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func + value. Stopping the TUN swaps `activeTunHandler` instead. +- `tunnel.AllProxies()` returns a shared, cached map — never modify it. The cache is invalidated by + `invalidateAllProxies` on `tunnel.UpdateProxies` and validated against each provider's `Version()`, so a rebuild + costs one read per provider rather than one per proxy. Anything else added to `tunnel/patch.go` that derives from the + proxy set needs both signals: the external controller can reload the config through `hub/route/configs.go` without + going through FlClash's `applyConfig`, so a hook on the FlClash side alone would miss a profile switch. +- Core state that mirrors mihomo state goes stale at the next `applyConfig`, which replaces every proxy, provider and + rule. Read the tunnel instead of caching a snapshot of it: `lookupExternalProvider` kept one that was rebuilt only + when the host asked for the provider list, and the host asks after a successful setup and not after a failed one, so + an update ran against a provider the tunnel no longer held — downloading, writing to disk and reporting success + against nothing. +- Selection writes take `selectMu`, not `configMu`. mihomo's `Selector.Set` has no lock of its own, so the writes need + mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is + what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under + `configMu`, fixing the order as `configMu` → `selectMu`. +- The delay-test semaphore is acquired with a slice of the caller's budget (`budget/delayTestQueueShare`), not + unconditionally and not with the whole deadline. Queueing and probing come out of one budget, so a test handed all of + it can spend it waiting and reach `URLTest` with nothing left, reporting a proxy it never contacted as unreachable. + The probe keeps the caller's original deadline, so whatever the queue did not use is still its own. +- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy: + `handleTestDelay` returns inside its own budget on every path. `asyncTestDelay` therefore returns null instead of a + `-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a + timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's. +- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0 + used to mean "testing", which let a result and the state of a test overwrite each other and left cards spinning + forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a + reply arriving; core status leaving `connected` cancels every run in flight. +- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background + scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the + batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData` + claims per resource and `updater.GeoUpdateHook` releases. +- A failed `applyConfig` rolls the tunnel back to the default config, and that rollback is the whole recovery: + `handleSetupConfig` returns the error and stops there. Do not add a teardown on top of it — stopping the listeners + takes the app offline over a profile the user can still switch away from, and the error already reaches the host, + which is what surfaces the failure (`MessageException` on the Flutter side, the config-error toast on Android). Keep + an empty `config.yaml` out of the failure path — it is how the app says "no profile selected", and `loadConfig` + resolves it to the defaults. +- Package `init` in the Android library runs while the `.so` is being loaded, so a panic there takes the application + down before it can report anything. `platform/limit.go` arms an fd-pressure probe and degrades to never blocking when + it cannot; keep that shape for anything else `init` sets up that correctness does not depend on. +- Every `android && cgo` file in `core/` is compiled only by the NDK-backed CI step in the `test` job. Keep the build + constraints as `android && cgo` / `!(android && cgo)`: a bare `cgo` constraint makes `go build ./...` fail in `core/` + on any developer machine, because the files it pulls in need the NDK. ## Lifecycle Rules +- Crash recovery is owned by `BootGuard` (`lib/common/boot_guard.dart`), and the signal it acts on is the persisted + `BootRecord`, never a crash reporter. The record is stamped `starting` before `startCore` and `running` once + `_initApp` finishes, so only a launch that died before reaching `running` is a failed launch — a crash after hours of + runtime, or a process the system reclaimed in the background, is not one. `ApplicationExitInfo` can only veto a + failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through + `handledExitAt` so the same exit cannot be counted twice. +- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and + `markClosed` return without touching preferences. The desktop has neither of the two attribution sources — no + `ApplicationExitInfo` equivalent and no Crashlytics toggle — so a bare sentinel there would read a window closed on + the disclaimer dialog, or a session ended by shutdown, as a failed launch. Keep the platform check inside the guard; + callers in `bootstrap.dart` and `SystemAction` stay unconditional. +- `FirebaseCrashlytics.didCrashOnPreviousExecution()` corroborates a failure and must not trigger one. Its marker file is + cleared by a background initialization that `dataCollectionArbiter` blocks while collection is disabled, which is the + default here, so one real crash makes it return true on every later launch. That latch is what made the app clear the + selected profile on every cold start; it is also why the probe is only read when `crashlytics` is enabled, keeping + Firebase uninitialized until the user consents. +- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from + `crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config. - 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 @@ -84,18 +214,143 @@ the invariant hard to break beats prose that asks the next reader not to break i broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation. - Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately. +- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to "`show` was never called". + `AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins. + Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)` + means `WM_DESTROY` never runs. +- The `tray` plugin owns call ordering, idempotency, serialization, and unchanged-payload suppression. Application code + declares desired state through one `Tray.show(TraySpec)` call and must not add platform branches to work around + ordering. Platform branches in `lib/common/tray.dart` are only for deliberate product differences (macOS speed title + and group submenus); query `Tray.instance.capabilities` for ability differences. +- Every native `show` returns whether the tray now reflects the payload, and reports `false` instead of showing a broken + icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather + than suppressed until restart. Any test that mocks the `tray` channel must return `true` from `show`. +- The delayed DNS re-check `NetworkObserveModule.onLosing` posts is deliberately left un-deduplicated. The runnable + re-reads `networkInfos` and does nothing when `onLost` already dropped the network, `updateDns` returns early when the + resolved list is unchanged, and `stop()` clears the handler queue, so a network that reports `onLosing` repeatedly + costs one comparison per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds + state that has to stay in sync with the map. ## Testing Rules The `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage -collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper; -verify cross-language protocol behavior through shared Dart contract tests under `test/core/` and native platform build -checks. +collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper, +plus an NDK-backed `GOOS=android` vet that covers the `android && cgo` files the first two exclude; verify cross-language +protocol behavior through shared Dart contract tests under `test/core/` and native platform build checks. + +A Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` — +leaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever +connection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with +`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test +connection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches +it in an unrelated test. Use `CoreController.test(mock)` to inject a mocked `CoreHandlerInterface`. Call `CoreController.resetInstance()` in `tearDown` to clean up the singleton between tests. Register fallback values for freezed params used with `any()` matchers. +`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a +group's floor when new tests lift it; do not lower one to make a run pass. + +Every measured group needs a floor. A group the report measures but `_groupFloors` does not declare fails the run, so +adding a top-level directory under `lib/` means adding its floor in the same change. Set a new floor at or just below +the coverage the directory actually has; the point is to stop a slide, not to backfill tests before the directory can +land. + +Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and +touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read +resolve to the same fake, so it cannot fail on a call site that still reaches for the global. + +Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid` +and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every +path. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`. + +Three globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it: +`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every +`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry +key), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early +under `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test +at all; `test/common/launch_test.dart` pins the early return instead. + +`pumpAndSettle` never returns on a page holding `EditorPage`: the code editor blinks its caret forever, so frames keep +being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in `common/task.dart` hand work to a real +isolate through `compute`, which only runs outside the fake-async zone, so a test awaiting one needs +`tester.runAsync(...)` between the pumps — see `test/views/profile_preview_test.dart`. + +The `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it +replaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the +setter is not, so closing there would either be unawaited or force the seam to become async for no gain. A test that +does open a file-backed database owns closing it. + +`system.isAndroid` / `isMacOS` / `isWindows` / `isLinux` read `dart:io` `Platform` and cannot be overridden, unlike +`debugDefaultTargetPlatformOverride`. A branch behind one of them is only ever exercised on a host that matches it, so CI +(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior, +and leave headroom under a group floor that covers such a branch. + +A platform decision that drives layout takes `isDesktop`/`isMacOS` as parameters and reads `system` only at the call site, +so every platform's outcome is reachable from one host. `getWindowHeaderHeight` and `showsWindowHeader` in +`lib/common/layout.dart` own the window header rule for both `WindowHeaderContainer` and `overlayTopOffset` — they must +agree, or the content is offset by a header that is not there. `WindowHeaderBar` takes its height and slots as arguments, +which is what lets `test/manager/window_header_test.dart` measure the Windows caption bar on a macOS host. + +A `Stack` under loose constraints sizes to its non-positioned children, and falls back to `constraints.biggest` only when +it has none — so a bar that fills the window must keep every slot positioned. One non-positioned child is enough to +collapse `WindowHeaderBar` to that child's width: the macOS title did exactly that, leaving the app name over the traffic +lights and the raw window painted black beside it, while Windows, whose slots are all positioned, stayed correct. +`WindowHeaderLayout` positions the header across the top for the same reason, and the macOS group asserts the bar's +width, not just its height — a height-only assertion cannot see this. + +The same seam carries the two other places a platform decision changed what was built: `AppTray` holds `isMacOS`/ +`isWindows` as state — `AppTray()` fills them from `system`, `AppTray.forPlatform` is the test seam — and `OnDemandView` +takes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because +`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS +shape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every +non-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window_manager` channel mock that +answers `isMaximized`/`isAlwaysOnTop` with a bool. + +Auto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`, +`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching +rebuilds the provider from its override and silently discards whatever the code under test wrote. Add +`container.listen(theProvider, (_, _) {})` in the harness, as `overwrite_stage_flow_test.dart` does. The staging flow also +re-arms its debounce when it clears the stage, so drain it (`pump` past the duration, then unmount) or the binding fails +the test on a pending timer. + +A field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`, +`PageController`, `AnimationController` or `StreamController` must be released in the same file. +`test/lint/disposable_field_test.dart` enforces this by scanning `lib/`, because no lint covers it: `close_sinks` only sees +sinks, and nothing in the standard set tracks `ChangeNotifier` disposal. A field that genuinely outlives its owner goes in +that test's `_allowed` set with the reason, not left bare. Controllers received as widget parameters belong to the caller +and are out of scope. + +An `IconButton` whose icon is an icon needs a `tooltip`. It is the button's only accessible name — without it TalkBack and +VoiceOver announce nothing and the desktop build shows no hover hint. `test/lint/icon_button_tooltip_test.dart` enforces +it and skips exactly two shapes: an `icon:` holding a `Text`, which is already a visible label, and +`views/dashboard/widgets/core_status_button.dart`, which takes its label from an enclosing `Tooltip` (a second test fails +if that wrapper disappears). Reuse an existing string before adding one; a label that depends on state goes on the button +inside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden +behind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern. + +A tooltip needs an `Overlay` ancestor at build time, not at hover time: `RawTooltip` builds an `OverlayPortal`, so a +button whose `tooltip` has nowhere to go throws "No Overlay widget found" and takes its whole subtree down with it. +Everything `buildManagerStack` wraps around `MaterialApp.builder`'s child sits *above* the app Navigator and therefore +above the only Overlay in the tree. A manager that renders a tooltip — `WindowHeaderLayout` and its caption buttons are +the case that broke Windows while macOS, which renders a bare title there, stayed clean — hosts its own with +`Overlay.wrap`, spanning the window so the tooltip is not clipped to the widget that owns it. A test only reproduces this +by mirroring that topology: build the widget from `MaterialApp.builder`, never from `home:`. + +A public top-level declaration that nothing outside its own file references is dead, and no lint catches it: +`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every +"unused import" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the +`final appPath = AppPath()` singletons next to them — appear nowhere else, counting generated code as a consumer (a +riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or +typedefs are skipped: those are reached through the types they attach to, never by name. + +A `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then +immediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown +throws "`…State.dispose failed to call super.dispose.`" in debug and profile builds. Declare the override as `void +dispose()` and hand async teardown to `unawaited(...)`; `Future dispose() async` compiles and is the shape that +invites the bug. + Use `ProviderContainer` directly for simple Riverpod provider tests. The generated Riverpod `update()` method takes a callback: ```dart @@ -107,6 +362,53 @@ When testing freezed models with nested objects, always round-trip through `json For async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success, failure, disposal, and any timer boundary that changes visible state. +## Commit Messages + +Subjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs +`tool/check_commit_msg.sh`: + +```text +[(scope)][!]: +``` + +- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`. +- Scope is optional and lower case; use a comma to list several, as in `fix(core,android)`. +- `!` before the colon marks a breaking change. +- Descriptions start in lower case, omit the trailing period, and keep the whole subject within 100 characters. + Identifiers and acronyms keep their own casing, as in `fix(ui): AppBar text is truncated`. +- `Merge`/`Revert` subjects and `fixup!`/`squash!` commits are exempt. +- No `Co-authored-by` trailer crediting a coding agent, whatever that tool's own convention says. The history + records who owns the change, not which tool typed it; human co-authors are still fine. The hook rejects the + known agent identities. + +Write what the change does, not that something changed: `perf(views): stop redoing per-frame work in build`, not +`Optimize more details`. + +Install the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`. + +### Changelog Trailers + +`tool/changelog.dart` builds the user facing changelog from the commit history, so the trailers below are the copy that +ships to users. The subject stays the developer facing summary and is only the fallback. + +```text +feat(profiles): support per-profile override script + +Changelog: Per-profile override scripts +``` + +- `Changelog:` is the English entry. `Changelog: skip` drops the commit from the changelog entirely. +- The changelog is English only. Translation trailers were removed on purpose: they pushed release copy into the commit + history, so any `Changelog-:` or `Breaking-:` now fails the hook. Translate after the fact if ever + needed, not in the commit message. +- `Changelog-Type:` moves an entry into another group, for example to promote a `refactor` that users will notice. Valid + values are `breaking`, `feat`, `fix`, `perf`, `revert`. +- `BREAKING CHANGE:` is required whenever the subject carries `!`, and its text becomes the breaking entry. A `!` commit + therefore needs two lines of copy: the footer for the breaking entry and `Changelog:` for the normal one. + +`feat`, `fix`, `perf`, `revert` and breaking commits are collected by default; every other type is dropped unless it +carries a `Changelog:` trailer. Commits missing a trailer reuse their subject, and the hook says so without blocking. + ## Generated Code Do not manually edit generated files under: @@ -118,3 +420,23 @@ Do not manually edit generated files under: - `lib/l10n/intl/` After schema, model, or provider changes, run build generation and include focused tests when behavior changes. + +`lib/l10n/l10n.dart` is the one file that still imports `package:flutter/material.dart`. +`intl_utils` hardcodes that import in its own template, so regenerating rewrites it and +there is nothing to fix here; `test/lint/design_package_test.dart` exempts the generated +l10n paths for that reason. It is harmless because the file only needs `Locale`, +`BuildContext`, `Localizations` and `LocalizationsDelegate`, which the legacy library and +`material_ui` both re-export from the same `package:flutter/widgets.dart`. Everything a +human writes takes Material from `material_ui`; `cupertino_ui` is banned outright and +survives only as a transitive dependency of `material_ui`. + +Strings live in `arb/intl_{en,zh_CN,ja,ru}.arb` — flat JSON, no `@` metadata. Add a key to all four, then regenerate with +`dart run intl_utils:generate`, which rewrites `lib/l10n/`. A key present in only some locales silently falls back to +English at runtime, so add the translation rather than leaving it out. + +Some labels are not reached through the generated `AppLocalizations` getters at all. `Intl.message()` +builds the key from an enum name or a stored string — `action_${HotAction.name}`, `${DynamicSchemeVariant.name}Scheme`, +`NavigationItem.description`. The analyzer sees nothing, and a failed lookup returns the key itself, so a stale key ships +as `routeMode_config` in the UI rather than throwing. `test/lint/dynamic_message_key_test.dart` expands those families +from the real enums and fails when a derived key is missing from any locale; every `Intl.message` site in `lib` must be +registered there, so a new dynamic key cannot be added without also declaring what builds it. diff --git a/.agents/skills/ui-work/SKILL.md b/.agents/skills/ui-work/SKILL.md index 2f94062cec..8326341096 100644 --- a/.agents/skills/ui-work/SKILL.md +++ b/.agents/skills/ui-work/SKILL.md @@ -30,6 +30,68 @@ Use this for user-facing Flutter UI changes in `lib/`, including widgets, screen flutter test test/widgets/ ``` +## Corner Radii + +All corner radii come from `lib/common/shape.dart`. Never write a radius literal in a widget. + +The scale is picked by the component's **shortest side**, not by what looks good in isolation. A radius that reads as +a soft card at 64 logical pixels tall reads as a pill at 24 and as a square at 400, so a single radius everywhere is +the wrong kind of consistency. + +| Token | Value | Shortest side | Use | +| --- | --- | --- | --- | +| `none` | 0 | - | square edges, and the flat side of a grouped run | +| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface | +| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles | +| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups, FAB | +| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards | +| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers | +| `xxl` | 28 | over 200 | sheets, dialogs, full-screen containers | +| `full` | 1000 | - | pills and circles: tracks, indicators, progress, avatars | + +`AppCorner.fit(shortestSide)` applies that table at runtime and is the right call whenever the size comes from a +`LayoutBuilder` or scales with text size. It snaps to the largest token that stays at or under one third of the +shortest side, which is the rule the table encodes. + +- `AppCorner` holds the scale as `double`, for `radius:` on `CommonCard` and for arithmetic. +- `AppRadius` mirrors it as `BorderRadius`, plus `all`, `top`, and `vertical` builders. +- `AppShape` mirrors it as `RoundedSuperellipseBorder`, plus `full` (stadium), `circle`, `input`, and the + `all`/`top`/`vertical`/`of` builders. +- `ThemeData.withAppShapes` in `lib/application.dart` applies the scale to card, dialog, bottom sheet, snack bar, + chip, menu, input, FAB, navigation indicator, and progress themes. Do not restate those shapes at call sites; in + particular, leave `InputDecoration.border` unset so inputs inherit `AppShape.input`. + +The three tiers between `md` and `xxl` each answer to a different size class, and the split is what keeps one radius +from being wrong for two of them. + +`xl` is for a surface that spans the full width. A wide, short strip needs a larger corner than its height alone +suggests, or it stops reading as a card. Everything full-bleed shares this token and must stay on it: +`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem` +(`generateSectionV3`), and the proxy group header in `lib/views/proxies/list.dart`. The value is deliberately under +half the height of a standard row, so nothing gets clamped and every one of them renders at the identical radius no +matter which is taller. This is the one place the one-third rule is knowingly overshot; the width is what carries +it, and `fit()` is not used here. + +`lg` is for the mid-size card that is not full-bleed: dashboard tiles and proxy node cards. It exists because `xl` +does not fit all of them. A proxy node card's height follows the user's `ProxyCardType` setting, and at `min` it is +only about 64 tall, which caps its radius at 21 — so `xl` would break on a setting the user can change at any time, +while `lg` clears every one of the three heights. The same value has room to spare on the smallest dashboard tile, +about 177x80, so both surfaces can hold one token instead of branching per size. + +Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. That one subtracts the +floating label's notch from the outline as a two-pixel band along the top edge, which assumes the label sits over a +flat run of border. Any radius from 8 up puts the corner curve under the notch instead, the subtraction takes the +corner with it, and the top edge is left drawn a pixel low. `AppInputBorder` clips the notch region away and paints +the full superellipse through it, so the corner arc is truncated exactly where the notch begins. That is what +Flutter's own `OutlineInputBorder` does by shortening the corner arc's sweep, which is why the framework border +survives large radii and the package one does not. The border needs no `contentPadding` compensation; leave Material's +defaults alone. + +Nested radii are derived, never tokens. Concentric corners need `outer = inner + inset`, so name the inset and +compute the outer value: `_cardRadius` in `lib/widgets/popup.dart`, `_kCornerRadius` in `lib/widgets/tab.dart`, and +the selection ring in `lib/widgets/palette.dart` all do this. Adding an intermediate token to spell one of these out +is what makes a scale grow without bound. + ## Pitfalls - Do not introduce a new visual system for one screen. diff --git a/.github/scripts/generate_release_notes.sh b/.github/scripts/generate_release_notes.sh deleted file mode 100644 index 25fa71a6e9..0000000000 --- a/.github/scripts/generate_release_notes.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -previous_tag="${1:-}" -output="${2:-release.md}" - -: > "$output" - -append_range() { - local range="$1" - - git log --no-merges --pretty=format:'%B' "$range" | - awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$output" -} - -current_tag="" -while IFS= read -r next_tag; do - if [[ -n "$current_tag" ]]; then - [[ "$current_tag" == "$previous_tag" ]] && break - append_range "$next_tag..$current_tag" - fi - current_tag="$next_tag" -done < <(git tag --merged HEAD --sort=-creatordate) - -if [[ -n "$current_tag" && "$current_tag" != "$previous_tag" ]]; then - append_range "$current_tag" -fi diff --git a/.github/scripts/generate_release_notes_test.sh b/.github/scripts/generate_release_notes_test.sh deleted file mode 100644 index 82a683afb8..0000000000 --- a/.github/scripts/generate_release_notes_test.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -generator="$script_dir/generate_release_notes.sh" -temp_dir="$(mktemp -d)" -trap 'rm -rf "$temp_dir"' EXIT - -repo="$temp_dir/repo" -git init --quiet --initial-branch=main "$repo" -git -C "$repo" config user.email "release-notes-test@example.com" -git -C "$repo" config user.name "Release notes test" - -commit() { - local message="$1" - local date="$2" - - GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ - git -C "$repo" commit --allow-empty --quiet --message "$message" -} - -commit "Initial release" "2025-01-01T00:00:00Z" -git -C "$repo" tag v1.0.0 - -commit "First release change" "2025-01-02T00:00:00Z" -commit "Update changelog" "2025-01-03T00:00:00Z" -git -C "$repo" tag v1.1.0 - -git -C "$repo" branch feature -git -C "$repo" checkout --quiet feature -commit $'Feature branch change\n\nFeature detail' "2025-01-04T00:00:00Z" -git -C "$repo" checkout --quiet main -GIT_AUTHOR_DATE="2025-01-05T00:00:00Z" \ - GIT_COMMITTER_DATE="2025-01-05T00:00:00Z" \ - git -C "$repo" merge --no-ff --quiet --message "Merge feature" feature -git -C "$repo" tag v1.2.0 - -expected="$temp_dir/expected.md" -actual="$temp_dir/actual.md" -printf '%s\n' \ - "- Feature branch change" \ - "" \ - "- Feature detail" \ - "" \ - "- First release change" \ - "" > "$expected" - -( - cd "$repo" - bash "$generator" v1.0.0 "$actual" -) -diff -u "$expected" "$actual" - -printf '%s\n' "stale content" > "$actual" -( - cd "$repo" - bash "$generator" v1.2.0 "$actual" -) -[[ ! -s "$actual" ]] - -( - cd "$repo" - bash "$generator" "" "$actual" -) -grep -q -- "- Initial release" "$actual" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0078a1e476..dba2524096 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -2,11 +2,18 @@ name: build on: push: + branches: + - '**' tags: - 'v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }} + env: IS_STABLE: ${{ !contains(github.ref, '-') }} - FLUTTER_VERSION: '3.44.4' + FLUTTER_VERSION: '3.47.1' jobs: test: @@ -17,10 +24,11 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive + fetch-depth: 0 - - name: Test release notes generation + - name: Test commit message hook shell: bash - run: bash .github/scripts/generate_release_notes_test.sh + run: bash tool/check_commit_msg_test.sh - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -32,34 +40,25 @@ jobs: - name: Install dependencies run: flutter pub get + - name: Check format + run: dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart + - name: Analyze run: flutter analyze --no-fatal-infos + - name: Verify changelog + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') + run: dart run tool/changelog.dart verify + - name: Run tests - run: flutter test --reporter expanded + run: flutter test --reporter expanded --coverage + + - name: Check coverage + run: dart run tool/check_coverage.dart coverage/lcov.info 75 - name: Validate local Flutter packages shell: bash - run: | - for package in plugins/proxy plugins/wifi_ssid plugins/window_ext plugins/setup; do - ( - cd "$package" - flutter pub get - flutter analyze --no-fatal-infos - ) - done - - - name: Run local plugin tests - shell: bash - run: | - ( - cd plugins/proxy - flutter test --reporter expanded - ) - ( - cd plugins/wifi_ssid - flutter test --reporter expanded - ) + run: bash tool/check_plugins.sh - name: Validate setup build tool working-directory: plugins/setup/buildkit/build_tool @@ -76,11 +75,77 @@ jobs: - name: Validate Go core wrapper working-directory: core + shell: bash env: CGO_ENABLED: 0 run: | - go test . + # `go test` succeeds silently when a package has no test files, so + # assert the wrapper actually ships tests before trusting the result. + if ! compgen -G '*_test.go' > /dev/null; then + echo 'No Go tests found in core/; the test step would pass vacuously.' >&2 + exit 1 + fi + test -z "$(gofmt -l ./*.go ./platform ./tun)" || { + gofmt -l ./*.go ./platform ./tun >&2 + exit 1 + } + go test -count=1 . go vet . + # `go vet .` only reaches the root package. `platform` and `tun` are + # behind build constraints, so they need an explicit GOOS to compile + # at all; without this they ship unvetted. + GOOS=linux go vet ./platform + + - name: Setup NDK + uses: nttld/setup-ndk@v1 + id: core-ndk + with: + ndk-version: r28c + + - name: Validate the Android core library + working-directory: core + shell: bash + env: + ANDROID_NDK_HOME: ${{ steps.core-ndk.outputs.ndk-path }} + run: | + # The step above pins CGO_ENABLED=0, which excludes every `android && + # cgo` file: lib.go, bride.go, tun/ and platform/limit.go. The job + # that builds them needs the NDK and only runs on a `v*` tag, so + # without this the entire Android surface of the core first compiles + # at release time. + CC="$(echo "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/aarch64-linux-android21-clang)" + test -x "$CC" || { echo "NDK clang not found at $CC" >&2; exit 1; } + # Same tags the shipped library is built with; read from the build + # tool's config so the two cannot drift. + tags="$(sed -n 's/^tags: *//p' ../plugins/setup/buildkit/build_tool/build_config.yaml)" + test -n "$tags" || { echo 'Could not read tags from build_config.yaml' >&2; exit 1; } + export CGO_ENABLED=1 GOOS=android GOARCH=arm64 CC + go vet "-tags=$tags" . ./tun ./platform + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Run Android unit tests + working-directory: android + shell: bash + run: | + # settings.gradle.kts resolves the Flutter tooling through local.properties, + # which only the Flutter build itself would otherwise generate. + { + echo "flutter.sdk=$FLUTTER_ROOT" + echo "sdk.dir=$ANDROID_HOME" + } > local.properties + wrapper="$FLUTTER_ROOT/bin/cache/artifacts/gradle_wrapper" + test -d "$wrapper" || flutter precache + cp -R "$wrapper/." . + chmod +x gradlew + # Modules are listed explicitly: the aggregate testDebugUnitTest task would + # also run third-party Flutter plugin suites. `:core` is omitted because its + # only class loads the native library through System.loadLibrary. + ./gradlew :common:testDebugUnitTest :service:testDebugUnitTest :app:testDebugUnitTest - name: Validate Rust components run: | @@ -144,6 +209,7 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive + fetch-depth: 0 - name: Setup Android Signing if: startsWith(matrix.platform,'android') @@ -203,61 +269,6 @@ jobs: path: ./dist overwrite: true - changelog: - if: ${{ !contains(github.ref, '-') }} - permissions: - contents: write - runs-on: ubuntu-latest - needs: [ build ] - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: refs/heads/main - - name: Generate - run: | - last_ver=$(grep -m1 '^## ' CHANGELOG.md 2>/dev/null | sed 's/^## //') - - mapfile -t tags < <(git tag --merged HEAD --sort=-creatordate) - - temp="NEW_CHANGELOG.md" - : > "$temp" - - for i in "${!tags[@]}"; do - curr="${tags[i]}" - [[ "$curr" == "$last_ver" ]] && break - - prev="${tags[i+1]}" - range="${prev:+$prev..}$curr" - - echo -e "## $curr\n" >> "$temp" - git log --no-merges --pretty=format:"%B" "$range" | \ - awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$temp" - done - [ -f CHANGELOG.md ] && cat CHANGELOG.md >> "$temp" - - mv "$temp" CHANGELOG.md - - - name: Commit - run: | - git add CHANGELOG.md - if ! git diff --cached --quiet; then - echo "Commit pushing" - git config --local user.email "chen08209@gmail.com" - git config --local user.name "chen08209" - git commit -m "Update changelog" - if git push; then - echo "Push succeeded" - else - echo "Push failed" - exit 1 - fi - fi - - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - upload: permissions: contents: write @@ -284,14 +295,31 @@ jobs: pattern: artifact-* merge-multiple: true - - name: Generate release.md + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Generate release notes shell: bash + env: + IS_STABLE: ${{ env.IS_STABLE }} + TAG: ${{ github.ref_name }} run: | - pre_tag=$( - curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" | - sed -nE 's/.*"tag_name": "([^"]+)".*/\1/p' - ) - bash .github/scripts/generate_release_notes.sh "$pre_tag" release.md + target=() + if [[ "$IS_STABLE" == 'true' ]]; then + target=(--tag "$TAG") + else + dart run tool/changelog.dart build --unreleased + fi + dart run tool/changelog.dart render release "${target[@]}" --out release.md + dart run tool/changelog.dart render telegram "${target[@]}" --out telegram.md + - name: Push to telegram env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} diff --git a/.gitignore b/.gitignore index c3bd0c480c..d4410d3f45 100644 --- a/.gitignore +++ b/.gitignore @@ -68,7 +68,6 @@ docs/ /macos/build/ /env.json /core_sha256.json -/core/*.exe /plugins/rust_api/.claude/ devtools_options.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bc1bb0a2b3..b34ed67dd4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,9 +6,15 @@ repos: entry: dart format language: system files: \.dart$ + stages: [pre-commit] - id: flutter-analyze name: flutter analyze (pre-push) entry: flutter analyze --no-fatal-infos language: system pass_filenames: false stages: [pre-push] + - id: commit-msg-format + name: conventional commit message + entry: tool/check_commit_msg.sh + language: system + stages: [commit-msg] diff --git a/AGENTS.md b/AGENTS.md index f5fc8089cb..6734c5e16c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ Read these only when the task touches their area: - Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded requests. +- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to. + The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules. - Follow `analysis_options.yaml`, especially single quotes, trailing commas, `child:` last, no `print()`, const/final preferences, and declared return types. - For CI parity, verify with `flutter pub get`, `flutter analyze --no-fatal-infos`, and diff --git a/CHANGELOG.md b/CHANGELOG.md index 0672215d74..5638a4d979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1028 +1,721 @@ -## v0.8.96 +# Changelog -- Optimize commented policy +## v0.8.96 (2026-08-17) -- Fix whole group delay test failing on Windows +**Bug Fixes** -- Optimize package icon loading and connections polling +- Fix whole group delay test failing on Windows (7fb4f4f) -## v0.8.95 +**Performance** -- Optimize core service +- Optimize package icon loading and connections polling (903e2b8) -- Optimize Android TV launcher icon + + -- Optimize back navigation +## v0.8.95 (2026-08-14) +- Optimize core service +- Optimize Android TV launcher icon +- Optimize back navigation - Optimize more details - - Fix some issues - - Optimize app layout - - Optimize focus control - - Adjust android process -## v0.8.94 +## v0.8.94 (2026-07-11) - Fix macos performance issue - - Support custom global-ua - - Update core - - Optimize some details - - Fix linux silent launching not working -## v0.8.93 +## v0.8.93 (2026-05-29) - Support custom overwrite - - Support run on demand - - Optimize windows ipc - - Optimize windows arm64 - - Optimize build - - Optimize some details - - Update core -## v0.8.92 +## v0.8.92 (2026-02-02) - Add sqlite store - - Optimize android quick action - - Optimize backup and restore - - Optimize more details -## v0.8.91 +## v0.8.91 (2025-12-12) - Fix windows some issues - - Optimize overwrite handle - - Optimize access control page - - Optimize some details -## v0.8.90 +## v0.8.90 (2025-10-08) - Fix android tile service - - Support append system DNS - - Fix some issues - - Update changelog -## v0.8.89 +## v0.8.89 (2025-09-27) - Fix some issues - - Optimize Windows service mode - - Update core - - Update changelog -## v0.8.88 +## v0.8.88 (2025-09-23) - Add android separates the core process - - Support core status check and force restart - - Optimize proxies page and access page - - Update flutter and pub dependencies - - Update go version - - Optimize more details - - Update changelog -## v0.8.87 +## v0.8.87 (2025-07-29) - Optimize desktop view - - Optimize logs, requests, connection pages - - Optimize windows tray auto hide - - Optimize some details - - Update core - - Update changelog -## v0.8.86 +## v0.8.86 (2025-06-15) - Fix windows tun issues - - Optimize android get system dns - - Optimize more details - - Update changelog -## v0.8.85 +## v0.8.85 (2025-06-07) - Support override script - - Support proxies search - - Support svg display - - Optimize config persistence - - Add some scenes auto close connections - - Update core - - Optimize more details -## v0.8.84 +## v0.8.84 (2025-05-01) - Fix windows service verify issues - - Update changelog -## v0.8.83 +## v0.8.83 (2025-05-01) - Add windows server mode start process verify - - Add linux deb dependencies - - Add backup recovery strategy select - - Support custom text scaling - - Optimize the display of different text scale - - Optimize windows setup experience - - Optimize startTun performance - - Optimize android tv experience - - Optimize default option - - Optimize computed text size - - Optimize hyperOS freeform window - - Add developer mode - - Update core - - Optimize more details - - Add issues template - - Update changelog -## v0.8.82 +## v0.8.82 (2025-04-18) - Optimize android vpn performance - - Add custom primary color and color scheme - - Add linux nad windows arm release - - Optimize requests and logs page - - Fix map input page delete issues - - Update changelog -## v0.8.81 +## v0.8.81 (2025-04-08) - Add rule override - - Update core - - Optimize more details - - Update changelog -## v0.8.80 +## v0.8.80 (2025-03-10) - Optimize dashboard performance - - Fix some issues - - Fix unselected proxy group delay issues - - Fix asn url issues - - Update changelog -## v0.8.79 +## v0.8.79 (2025-03-07) - Fix tab delay view issues - - Fix tray action issues - - Fix get profile redirect client ua issues - - Fix proxy card delay view issues - - Add Russian, Japanese adaptation - - Fix some issues - - Update changelog -## v0.8.78 +## v0.8.78 (2025-03-05) - Fix list form input view issues - - Fix traffic view issues - - Update changelog -## v0.8.77 +## v0.8.77 (2025-03-05) - Optimize performance - - Update core - - Optimize core stability - - Fix linux tun authority check error - - Fix some issues - - Fix scroll physics error - - Update changelog -## v0.8.75 +## v0.8.75 (2025-02-09) - Add windows storage corruption detection - - Fix core crash caused by windows resource manager restart - - Optimize logs, requests, access to pages - - Fix macos bypass domain issues - - Update changelog -## v0.8.74 +## v0.8.74 (2025-02-03) - Fix some issues - - Update changelog -## v0.8.73 +## v0.8.73 (2025-02-02) - Update popup menu - - Add file editor - - Fix android service issues - - Optimize desktop background performance - - Optimize android main process performance - - Optimize delay test - - Optimize vpn protect - - Update changelog -## v0.8.72 +## v0.8.72 (2025-01-10) - Update core - - Fix some issues - - Update changelog -## v0.8.71 +## v0.8.71 (2025-01-09) - Remake dashboard - - Optimize theme - - Optimize more details - - Update flutter version - - Update changelog -## v0.8.70 +## v0.8.70 (2024-12-09) - Support better window position memory - - Add windows arm64 and linux arm64 build script - - Optimize some details -## v0.8.69 +## v0.8.69 (2024-12-06) - Remake desktop - - Optimize change proxy - - Optimize network check - - Fix fallback issues - - Optimize lots of details - - Update change.yaml - - Fix android tile issues - - Fix windows tray issues - - Support setting bypassDomain - - Update flutter version - - Fix android service issues - - Fix macos dock exit button issues - - Add route address setting - - Optimize provider view - - Update changelog - - Update CHANGELOG.md -## v0.8.67 +## v0.8.67 (2024-11-09) - Add android shortcuts - - Fix init params issues - - Fix dynamic color issues - - Optimize navigator animate - - Optimize window init - - Optimize fab - - Optimize save -## v0.8.66 +## v0.8.66 (2024-10-26) - Fix the collapse issues - - Add fontFamily options -## v0.8.65 +## v0.8.65 (2024-10-26) - Update core version - - Update flutter version - - Optimize ip check - - Optimize url-test -## v0.8.64 +## v0.8.64 (2024-10-12) - Update release message - - Init auto gen changelog - - Fix windows tray issues - - Fix urltest issues - - Add auto changelog - - Fix windows admin auto launch issues - - Add android vpn options - - Support proxies icon configuration - - Optimize android immersion display - - Fix some issues - - Optimize ip detection - - Support android vpn ipv6 inbound switch - - Support log export - - Optimize more details - - Fix android system dns issues - - Optimize dns default option - - Fix some issues - - Update readme -## v0.8.60 +## v0.8.60 (2024-09-17) - Fix build error2 - - Fix build error - - Support desktop hotkey - - Support android ipv6 inbound - - Support android system dns - - fix some bugs -## v0.8.59 +## v0.8.59 (2024-09-09) - Fix delete profile error -## v0.8.58 +## v0.8.58 (2024-09-08) - Fix submit error 2 - - Fix submit error - - Optimize DNS strategy - - Fix the problem that the tray is not displayed in some cases - - Optimize tray - - Update core - - Fix some error -## v0.8.57 +## v0.8.57 (2024-09-02) - Fix tun update issues - - Add DNS override - Fixed some bugs - Optimize more detail - - Add Hosts override -## v0.8.56 +## v0.8.56 (2024-08-26) - fix android tip error - fix windows auto launch error -## v0.8.55 +## v0.8.55 (2024-08-25) - Fix windows tray issues - - Optimize windows logic - - Optimize app logic - - Support windows administrator auto launch - - Support android close vpn -## v0.8.53 +## v0.8.53 (2024-08-15) - Change flutter version - - Support profiles sort - - Support windows country flags display - - Optimize proxies page and profiles page columns -## v0.8.52 +## v0.8.52 (2024-08-11) - Update flutter version - - Update version - - Update timeout time - - Update access control page - - Fix bug -## v0.8.51 +## v0.8.51 (2024-08-05) - Optimize provider page - - Optimize delay test - - Support local backup and recovery - - Fix android tile service issues -## v0.8.49 +## v0.8.49 (2024-07-31) - Fix linux core build error - - Add proxy-only traffic statistics - - Update core - - Optimize more details - - Merge pull request #140 from txyyh/main - - 添加自建 F-Droid 仓库相关 workflow - Rename readme fingerprint - - Rename workflow deploy repo name - - Add download guide to README - - Add push release files to fdroid-repo -## v0.8.48 +## v0.8.48 (2024-07-25) - Optimize proxies page - - Fix ua issues - - Optimize more details -## v0.8.47 +## v0.8.47 (2024-07-22) - Fix windows build error -## v0.8.46 +## v0.8.46 (2024-07-22) - Update app icon - - Fix desktop backup error - - Optimize request ua - - Change android icon - - Optimize dashboard -## v0.8.44 +## v0.8.44 (2024-07-18) - Remove request validate certificate - - Sync core -## v0.8.43 +## v0.8.43 (2024-07-18) - Fix windows error -## v0.8.42 +## v0.8.42 (2024-07-18) - Fix setup.dart error - - Fix android system proxy not effective - - Add macos arm64 -## v0.8.41 +## v0.8.41 (2024-07-17) - Optimize proxies page - - Support mouse drag scroll - - Adjust desktop ui - - Revert "Fix android vpn issues" - - This reverts commit 891977408e6938e2acd74e9b9adb959c48c79988. -## v0.8.40 +## v0.8.40 (2024-07-15) - Fix android vpn issues - - Fix android vpn issues - - Rollback partial modification -## v0.8.39 +## v0.8.39 (2024-07-15) - Fix the problem that ui can't be synchronized when android vpn is occupied by an external - - Override default socksPort,port -## v0.8.38 +## v0.8.38 (2024-07-14) - Fix fab issues -## v0.8.37 +## v0.8.37 (2024-07-14) - Update version - - Fix the problem that vpn cannot be started in some cases - - Fix the problem that geodata url does not take effect -## v0.8.36 +## v0.8.36 (2024-07-13) - Update ua - - Fix change outbound mode without check ip issues - - Separate android ui and vpn - - Fix url validate issues 2 - - Add android hidden from the recent task - - Add geoip file - - Support modify geoData URL -## v0.8.35 +## v0.8.35 (2024-07-07) - Fix url validate issues - - Fix check ip performance problem - - Optimize resources page -## v0.8.34 +## v0.8.34 (2024-07-04) - Add ua selector - - Support modify test url - - Optimize android proxy - - Fix the error that async proxy provider could not selected the proxy -## v0.8.33 +## v0.8.33 (2024-07-01) - Fix android proxy error - - Fix submit error - - Add windows tun - - Optimize android proxy - - Optimize change profile - - Update application ua - - Optimize delay test -## v0.8.32 +## v0.8.32 (2024-06-28) - Fix android repeated request notification issues -## v0.8.31 +## v0.8.31 (2024-06-28) - Fix memory overflow issues -## v0.8.30 +## v0.8.30 (2024-06-27) - Optimize proxies expansion panel 2 - - Fix android scan qrcode error -## v0.8.29 +## v0.8.29 (2024-06-27) - Optimize proxies expansion panel - - Fix text error -## v0.8.28 +## v0.8.28 (2024-06-26) - Optimize proxy - - Optimize delayed sorting performance - - Add expansion panel proxies page - - Support to adjust the proxy card size - - Support to adjust proxies columns number - - Fix autoRun show issues - - Fix Android 10 issues - - Optimize ip show -## v0.8.26 +## v0.8.26 (2024-06-22) - Add intranet IP display - - Add connections page - - Add search in connections, requests - - Add keyword search in connections, requests, logs - - Add basic viewing editing capabilities - - Optimize update profile -## v0.8.25 +## v0.8.25 (2024-06-19) - Update version - - Fix the problem of excessive memory usage in traffic usage. - - Add lightBlue theme color - - Fix start unable to update profile issues - - Fix flashback caused by process -## v0.8.23 +## v0.8.23 (2024-06-16) - Add build version - - Optimize quick start - - Update system default option -## v0.8.22 +## v0.8.22 (2024-06-16) - Update build.yml - - Fix android vpn close issues - - Add requests page - - Fix checkUpdate dark mode style error - - Fix quickStart error open app - - Add memory proxies tab index - - Support hidden group - - Optimize logs - - Fix externalController hot load error -## v0.8.21 +## v0.8.21 (2024-06-13) - Add tcp concurrent switch - - Add system proxy switch - - Add geodata loader switch - - Add external controller switch - - Add auto gc on trim memory - - Fix android notification error -## v0.8.20 +## v0.8.20 (2024-06-12) - Fix ipv6 error - - Fix android udp direct error - - Add ipv6 switch - - Add access all selected button - - Remove android low version splash -## v0.8.19 +## v0.8.19 (2024-06-10) - Update version - - Add allowBypass - - Fix Android only pick .text file issues -## v0.8.18 +## v0.8.18 (2024-06-09) - Fix search issues -## v0.8.17 +## v0.8.17 (2024-06-09) - Fix LoadBalance, Relay load error - - Fix build.yml4 - - Fix build.yml3 - - Fix build.yml2 - - Fix build.yml - - Add search function at access control - - Fix the issues with the profile add button to cover the edit button - - Adapt LoadBalance and Relay - - Add arm - - Fix android notification icon error -## v0.8.16 +## v0.8.16 (2024-06-08) - Add one-click update all profiles - Add expire show -## v0.8.15 +## v0.8.15 (2024-06-06) - Temp remove tun mode - - Remove macos in workflow - - Change go version -## v0.8.14 +## v0.8.14 (2024-06-06) - Update Version - - Fix tun unable to open -## v0.8.13 +## v0.8.13 (2024-06-06) - Optimize delay test2 - - Optimize delay test - - Add check ip - - add check ip request -## v0.8.12 +## v0.8.12 (2024-06-06) - Fix the problem that the download of remote resources failed after GeodataMode was turned on, which caused the application to flash back. - - Fix edit profile error - - Fix quickStart change proxy error - - Fix core version -## v0.8.10 +## v0.8.10 (2024-06-05) - Fix core version -## v0.8.9 +## v0.8.9 (2024-06-05) - Update file_picker - - Add resources page - - Optimize more detail - - Add access selected sorted - - Fix notification duplicate creation issue - - Fix AccessControl click issue -## v0.8.7 +## v0.8.7 (2024-05-31) - Fix Workflow - - Fix Linux unable to open - - Update README.md 3 - - Create LICENSE - Update README.md 2 - - Update README.md - - Optimize workFlow -## v0.8.6 +## v0.8.6 (2024-05-31) - optimize checkUpdate -## v0.8.5 +## v0.8.5 (2024-05-30) - Fix submit error -## v0.8.4 +## v0.8.4 (2024-05-30) - add WebDAV - - add Auto check updates - - Optimize more details - - optimize delayTest -## v0.8.2 +## v0.8.2 (2024-05-15) - upgrade flutter version -## v0.8.1 +## v0.8.1 (2024-05-15) - Update kernel - Add import profile via QR code image -## v0.8.0 +## v0.8.0 (2024-05-11) - Add compatibility mode and adapt clash scheme. -## v0.7.14 +## v0.7.14 (2024-05-07) - update Version - - Reconstruction application proxy logic -## v0.7.13 +## v0.7.13 (2024-05-06) - Fix Tab destroy error -## v0.7.12 +## v0.7.12 (2024-05-06) - Optimize repeat healthcheck -## v0.7.11 +## v0.7.11 (2024-05-06) - Optimize Direct mode ui -## v0.7.10 +## v0.7.10 (2024-05-06) - Optimize Healthcheck - - Remove proxies position animation, improve performance - Add Telegram Link - - Update healthcheck policy - - New Check URLTest - - Fix the problem of invalid auto-selection -## v0.7.8 +## v0.7.8 (2024-05-05) - New Async UpdateConfig - - add changeProfileDebounce - - Update Workflow - - Fix ChangeProfile block - - Fix Release Message Error -## v0.7.7 +## v0.7.7 (2024-05-04) - Update Selector 2 -## v0.7.6 +## v0.7.6 (2024-05-04) - Update Version - - Fix Proxies Select Error -## v0.7.5 +## v0.7.5 (2024-05-03) - Fix the problem that the proxy group is empty in global mode. - - Fix the problem that the proxy group is empty in global mode. -## v0.7.4 +## v0.7.4 (2024-05-03) - Add ProxyProvider2 -## v0.7.3 +## v0.7.3 (2024-05-03) - Add ProxyProvider - - Update Version - - Update ProxyGroup Sort - - Fix Android quickStart VpnService some problems -## v0.7.1 +## v0.7.1 (2024-05-01) - Update version - - Set Android notification low importance - - Fix the issue that VpnService can't be closed correctly in special cases - - Fix the problem that TileService is not destroyed correctly in some cases - - Adjust tab animation defaults - - Add Telegram in README_zh_CN.md - - Add Telegram -## v0.7.0 +## v0.7.0 (2024-04-30) - update mobile_scanner - -- Initial commit \ No newline at end of file +- Initial commit diff --git a/analysis_options.yaml b/analysis_options.yaml index bb1a3a6772..b72386bf47 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,43 +1,16 @@ -include: package:flutter_lints/flutter.yaml +include: lint_options.yaml + analyzer: exclude: - build/** - lib/l10n/intl/** - lib/**/generated/** - plugins/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** errors: invalid_annotation_target: ignore - -linter: - rules: - # Style - prefer_single_quotes: true - require_trailing_commas: true - sort_child_properties_last: true - - # Avoid unnecessary code - unnecessary_new: true - unnecessary_this: true - unnecessary_const: true - avoid_print: true - avoid_unnecessary_containers: true - sized_box_for_whitespace: true - - # Prefer best practices - prefer_const_constructors: true - prefer_const_declarations: true - prefer_final_locals: true - prefer_final_in_for_each: true - prefer_is_empty: true - prefer_is_not_empty: true - prefer_interpolation_to_compose_strings: true - - # Type safety - always_declare_return_types: true - annotate_overrides: true - use_key_in_widget_constructors: true - no_leading_underscores_for_local_identifiers: true - - # Async - use_build_context_synchronously: true - unnecessary_await_in_return: true \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5d37e8851f..40799875eb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -81,6 +81,11 @@ android { ) } } + + sourceSets { + // Unit tests live under android/tests/ instead of each module's src/test. + getByName("test").java.setSrcDirs(listOf("../tests/app")) + } } kotlin { @@ -105,4 +110,6 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.crashlytics.ndk) implementation(libs.firebase.analytics) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/android/app/src/main/kotlin/com/follow/clash/ServiceBroadcastReceiver.kt b/android/app/src/main/kotlin/com/follow/clash/ServiceBroadcastReceiver.kt index b92b808da7..78a915ed2c 100644 --- a/android/app/src/main/kotlin/com/follow/clash/ServiceBroadcastReceiver.kt +++ b/android/app/src/main/kotlin/com/follow/clash/ServiceBroadcastReceiver.kt @@ -6,21 +6,18 @@ import android.content.Intent import android.os.Handler import android.os.Looper import com.follow.clash.common.BroadcastAction +import com.follow.clash.common.BroadcastLease import com.follow.clash.common.GlobalState import com.follow.clash.common.action -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.launch class ServiceBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { val action = intent?.action ?: return val pendingResult = goAsync() - val finished = AtomicBoolean(false) + val lease = BroadcastLease { pendingResult.finish() } val timeout = Runnable { - if (finished.compareAndSet(false, true)) { - GlobalState.log("Broadcast handling timed out: $action") - pendingResult.finish() - } + lease.release { GlobalState.log("Broadcast handling timed out: $action") } } mainHandler.postDelayed(timeout, BROADCAST_TIMEOUT_MILLIS) GlobalState.launch { @@ -30,9 +27,7 @@ class ServiceBroadcastReceiver : BroadcastReceiver() { GlobalState.log("Unable to handle service broadcast $action: $error") } finally { mainHandler.removeCallbacks(timeout) - if (finished.compareAndSet(false, true)) { - pendingResult.finish() - } + lease.release() } } } diff --git a/android/app/src/main/kotlin/com/follow/clash/ServiceState.kt b/android/app/src/main/kotlin/com/follow/clash/ServiceState.kt index b198faa28b..b4d49fbcee 100644 --- a/android/app/src/main/kotlin/com/follow/clash/ServiceState.kt +++ b/android/app/src/main/kotlin/com/follow/clash/ServiceState.kt @@ -1,323 +1,37 @@ package com.follow.clash -import android.net.VpnService -import com.follow.clash.common.GlobalState import com.follow.clash.models.SharedState -import com.follow.clash.plugins.AppPlugin -import com.follow.clash.plugins.TilePlugin -import com.follow.clash.service.ServiceConfig -import com.follow.clash.service.models.NotificationParams -import com.follow.clash.service.models.VpnOptions -import com.google.gson.Gson import io.flutter.embedding.engine.FlutterEngine -import java.util.concurrent.atomic.AtomicReference -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.coroutines.resume - -enum class RunState { - STARTED, - STARTING, - STOPPING, - STOPPED, -} - -private const val MISSING_CONFIG_MESSAGE = "No configuration found." -private const val INVALID_CONFIG_MESSAGE = "Invalid configuration." -private const val VPN_PERMISSION_MESSAGE = "VPN permission required." -private const val START_FAILED_MESSAGE = "Failed to start service." object ServiceState { - private val transitionLock = Mutex() - private val startPreparationLock = Mutex() - private val mutableRunState = MutableStateFlow(RunState.STOPPED) - private val latestRequest = AtomicReference(RunRequest(running = false)) - - @Volatile - private var sharedState = SharedState() - - @Volatile - private var flutterEngine: FlutterEngine? = null - - val runState = mutableRunState.asStateFlow() - - private val runTimeMillis: Long - get() = ServiceController.getRunTimeMillis() - - private val appPlugin: AppPlugin? - get() = flutterEngine?.plugin() - - private val tilePlugin: TilePlugin? - get() = flutterEngine?.plugin() - - fun attachFlutterEngine(engine: FlutterEngine) { - flutterEngine = engine - } - - fun detachFlutterEngine(engine: FlutterEngine) { - if (flutterEngine === engine) { - flutterEngine = null - } - } - - suspend fun handleToggleAction() { - if (isRunningRequested()) { - handleStopAction() - } else { - handleStartAction() - } - } - - suspend fun refresh(): Long = transitionLock.withLock { - val current = runTimeMillis - mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED - current - } - - internal fun captureRequestToken(): RunRequest = latestRequest.get() - - /** - * Settles the state after the bound service was lost. [token] is the request that was current - * when the loss was observed, so a start that raced ahead of this callback keeps its intent. - */ - internal suspend fun handleServiceLost(token: RunRequest) = transitionLock.withLock { - if (runTimeMillis != 0L) { - return@withLock - } - if (!latestRequest.compareAndSet(token, RunRequest(running = false))) { - return@withLock - } - mutableRunState.value = RunState.STOPPED - } - - suspend fun handleStartAction() { - if (isRunningRequested()) { - return - } - val plugin = tilePlugin - if (plugin != null) { - plugin.handleStart() - return - } - loadPreferencesAndStart() - } - - suspend fun handleStopAction() { - if (!isRunningRequested()) { - return - } - val plugin = tilePlugin - if (plugin != null) { - plugin.handleStop() - return - } - GlobalState.application.showToast(sharedState.stopTip) - requestStop().await() - } - - suspend fun handleVpnRevokeAction() { - if (!ServiceController.isVpnServiceActive()) { - return - } - handleStopAction() - } - - fun requestStart(): Deferred { - val request = createRequest(running = true) - val result = CompletableDeferred() - val launchRequest: (Boolean) -> Unit = { shouldStart -> - if (!shouldStart) { - fail(request) - result.complete(false) - } else { - GlobalState.launch { - result.complete( - runCatching { start(request) } - .onFailure { error -> - GlobalState.log("Unable to process service start request: $error") - fail(request) - } - .getOrDefault(false), - ) - } - } - } - val plugin = appPlugin - if (plugin != null) { - plugin.requestNotificationPermission(launchRequest) - } else { - launchRequest(true) - } - return result - } - - fun requestStop(): Deferred { - val request = createRequest(running = false) - val result = CompletableDeferred() - GlobalState.launch { - result.complete( - runCatching { stop(request) } - .onFailure { error -> - GlobalState.log("Unable to process service stop request: $error") - } - .getOrDefault(false), - ) - } - return result - } - - fun syncSharedState(state: SharedState) { - sharedState = state - applySharedState() - } + private val machine = ServiceStateMachine(AndroidServiceStateHost) - private suspend fun loadPreferencesAndStart() { - sharedState = GlobalState.application.sharedState - if (sharedState.setupParams == null || sharedState.vpnOptions == null) { - GlobalState.application.showToast(MISSING_CONFIG_MESSAGE) - return - } - if (setupCore()) { - if (!requestStart().await()) { - GlobalState.application.showToast(START_FAILED_MESSAGE) - } - } - } + val runState = machine.runState - private fun applySharedState() { - GlobalState.setCrashlytics(sharedState.crashlytics) - ServiceConfig.updateNotificationParams( - NotificationParams( - title = sharedState.currentProfileName, - stopText = sharedState.stopText, - onlyStatisticsProxy = sharedState.onlyStatisticsProxy, - ), - ) - } + fun attachFlutterEngine(engine: FlutterEngine) = + AndroidServiceStateHost.attachFlutterEngine(engine) - private suspend fun setupCore(): Boolean { - applySharedState() - GlobalState.application.showToast(sharedState.startTip) - val initParams = Gson().toJson( - mapOf( - "home-dir" to GlobalState.application.filesDir.path, - "version" to android.os.Build.VERSION.SDK_INT, - ), - ) - val setupParams = Gson().toJson(sharedState.setupParams) - return ServiceController.quickSetup( - initParams, - setupParams, - ).fold( - onSuccess = { message -> - if (message.isEmpty()) { - true - } else { - GlobalState.log("Unable to set up core: $message") - showConfigError(message) - false - } - }, - onFailure = { error -> - GlobalState.log("Unable to set up core: $error") - showConfigError(error.message) - false - }, - ) - } + fun detachFlutterEngine(engine: FlutterEngine) = + AndroidServiceStateHost.detachFlutterEngine(engine) - private fun showConfigError(message: String?) { - GlobalState.application.showToast( - message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE, - ) - } + suspend fun handleToggleAction() = machine.handleToggleAction() - private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock { - if (!isCurrent(request)) { - return@withLock false - } - val options = sharedState.vpnOptions - if (options == null) { - fail(request) - return@withLock false - } - if (!prepareVpn(options)) { - if (appPlugin == null && isCurrent(request)) { - GlobalState.application.showToast(VPN_PERMISSION_MESSAGE) - } - fail(request) - return@withLock false - } - if (!isCurrent(request)) { - return@withLock false - } + suspend fun handleStartAction() = machine.handleStartAction() - transitionLock.withLock transition@{ - if (!isCurrent(request)) { - return@transition false - } - if (runState.value == RunState.STARTED && runTimeMillis != 0L) { - return@transition true - } - mutableRunState.value = RunState.STARTING - val startedAtMillis = ServiceController.start(options) - mutableRunState.value = - if (startedAtMillis == 0L) RunState.STOPPED else RunState.STARTED - if (startedAtMillis == 0L) { - fail(request) - return@transition false - } - isCurrent(request) - } - } + suspend fun handleStopAction() = machine.handleStopAction() - private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock { - if (!isCurrent(request)) { - return@withLock false - } - if (runState.value == RunState.STOPPED && runTimeMillis == 0L) { - return@withLock true - } - mutableRunState.value = RunState.STOPPING - ServiceController.stop() - mutableRunState.value = RunState.STOPPED - isCurrent(request) - } + suspend fun handleVpnRevokeAction() = machine.handleVpnRevokeAction() - private suspend fun prepareVpn(options: VpnOptions): Boolean { - val plugin = appPlugin - ?: return !options.enable || VpnService.prepare(GlobalState.application) == null - return suspendCancellableCoroutine { continuation -> - val callback: (Boolean) -> Unit = { granted -> - if (continuation.isActive) { - continuation.resume(granted) - } - } - continuation.invokeOnCancellation { - plugin.cancelVpnPreparation(callback) - } - plugin.prepareVpn(options.enable, callback) - } - } + suspend fun refresh(): Long = machine.refresh() - private fun createRequest(running: Boolean): RunRequest = - RunRequest(running).also(latestRequest::set) + fun requestStart(): Deferred = machine.requestStart() - private fun isRunningRequested(): Boolean = latestRequest.get().running + fun requestStop(): Deferred = machine.requestStop() - private fun isCurrent(request: RunRequest): Boolean = latestRequest.get() === request + fun syncSharedState(state: SharedState) = machine.syncSharedState(state) - private fun fail(request: RunRequest) { - latestRequest.compareAndSet(request, RunRequest(running = false)) - } + internal fun captureRequestToken(): RunRequest = machine.captureRequestToken() - internal class RunRequest( - val running: Boolean, - ) + internal suspend fun handleServiceLost(token: RunRequest) = machine.handleServiceLost(token) } diff --git a/android/app/src/main/kotlin/com/follow/clash/ServiceStateHost.kt b/android/app/src/main/kotlin/com/follow/clash/ServiceStateHost.kt new file mode 100644 index 0000000000..154c5e6975 --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/ServiceStateHost.kt @@ -0,0 +1,137 @@ +package com.follow.clash + +import android.net.VpnService +import com.follow.clash.common.GlobalState +import com.follow.clash.models.SharedState +import com.follow.clash.plugins.AppPlugin +import com.follow.clash.plugins.TilePlugin +import com.follow.clash.service.ServiceConfig +import com.follow.clash.service.models.NotificationParams +import com.follow.clash.service.models.VpnOptions +import io.flutter.embedding.engine.FlutterEngine +import kotlinx.coroutines.CoroutineScope + +/** + * Everything [ServiceStateMachine] needs from the Android runtime. + * + * The machine owns the arbitration and the transitions; this is only the part that cannot run on a + * plain JVM, so unit tests can drive the state machine with an in-memory implementation. + */ +internal interface ServiceStateHost { + val scope: CoroutineScope + val runTimeMillis: Long + val homeDirPath: String + val sdkInt: Int + + fun log(message: String) + + fun showToast(message: String) + + fun setCrashlytics(enabled: Boolean) + + fun updateNotificationParams(params: NotificationParams) + + fun loadSharedState(): SharedState + + fun isVpnPermissionGranted(): Boolean + + fun tile(): TileGateway? + + fun app(): AppGateway? + + suspend fun quickSetup(initParams: String, setupParams: String): Result + + suspend fun startService(options: VpnOptions): Long + + suspend fun stopService() + + suspend fun isVpnServiceActive(): Boolean +} + +/** The Quick Settings tile surface, backed by [TilePlugin] in production. */ +internal interface TileGateway { + fun handleStart() + + fun handleStop() +} + +/** The foreground-app surface, backed by [AppPlugin] in production. */ +internal interface AppGateway { + fun requestNotificationPermission(callback: (Boolean) -> Unit) + + fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) + + fun cancelVpnPreparation(callback: (Boolean) -> Unit) +} + +internal object AndroidServiceStateHost : ServiceStateHost { + @Volatile + private var flutterEngine: FlutterEngine? = null + + override val scope: CoroutineScope + get() = GlobalState + + override val runTimeMillis: Long + get() = ServiceController.getRunTimeMillis() + + override val homeDirPath: String + get() = GlobalState.application.filesDir.path + + override val sdkInt: Int + get() = android.os.Build.VERSION.SDK_INT + + fun attachFlutterEngine(engine: FlutterEngine) { + flutterEngine = engine + } + + fun detachFlutterEngine(engine: FlutterEngine) { + if (flutterEngine === engine) { + flutterEngine = null + } + } + + override fun log(message: String) = GlobalState.log(message) + + override fun showToast(message: String) = GlobalState.application.showToast(message) + + override fun setCrashlytics(enabled: Boolean) = GlobalState.setCrashlytics(enabled) + + override fun updateNotificationParams(params: NotificationParams) = + ServiceConfig.updateNotificationParams(params) + + override fun loadSharedState(): SharedState = GlobalState.application.sharedState + + override fun isVpnPermissionGranted(): Boolean = + VpnService.prepare(GlobalState.application) == null + + override fun tile(): TileGateway? = flutterEngine?.plugin()?.let { plugin -> + object : TileGateway { + override fun handleStart() = plugin.handleStart() + + override fun handleStop() = plugin.handleStop() + } + } + + override fun app(): AppGateway? = flutterEngine?.plugin()?.let { plugin -> + object : AppGateway { + override fun requestNotificationPermission(callback: (Boolean) -> Unit) = + plugin.requestNotificationPermission(callback) + + override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) = + plugin.prepareVpn(enable, callback) + + override fun cancelVpnPreparation(callback: (Boolean) -> Unit) = + plugin.cancelVpnPreparation(callback) + } + } + + override suspend fun quickSetup(initParams: String, setupParams: String): Result = + ServiceController.quickSetup(initParams, setupParams) + + override suspend fun startService(options: VpnOptions): Long = + ServiceController.start(options) + + override suspend fun stopService() = ServiceController.stop() + + override suspend fun isVpnServiceActive(): Boolean = ServiceController.isVpnServiceActive() +} diff --git a/android/app/src/main/kotlin/com/follow/clash/ServiceStateMachine.kt b/android/app/src/main/kotlin/com/follow/clash/ServiceStateMachine.kt new file mode 100644 index 0000000000..4212b0b9e7 --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/ServiceStateMachine.kt @@ -0,0 +1,344 @@ +package com.follow.clash + +import com.follow.clash.common.RunIntentArbiter +import com.follow.clash.models.SharedState +import com.follow.clash.service.models.NotificationParams +import com.follow.clash.service.models.VpnOptions +import com.google.gson.Gson +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.resume + +enum class RunState { + STARTED, + STARTING, + STOPPING, + STOPPED, +} + +internal typealias RunRequest = RunIntentArbiter.Token + +internal const val MISSING_CONFIG_MESSAGE = "No configuration found." +internal const val INVALID_CONFIG_MESSAGE = "Invalid configuration." +internal const val VPN_PERMISSION_MESSAGE = "VPN permission required." +internal const val START_FAILED_MESSAGE = "Failed to start service." + +/** + * Serializes run intents onto the bound background service. + * + * Callers request a transition; the newest request always wins. Every step that outlives its own + * suspension point re-checks [isCurrent] before it publishes anything, so a start that was overtaken + * by a stop cannot report itself as started. + */ +internal class ServiceStateMachine(private val host: ServiceStateHost) { + private val transitionLock = Mutex() + private val startPreparationLock = Mutex() + private val mutableRunState = MutableStateFlow(RunState.STOPPED) + private val arbiter = RunIntentArbiter() + + @Volatile + private var sharedState = SharedState() + + @Volatile + private var pendingVpnPreparation: (() -> Unit)? = null + + val runState = mutableRunState.asStateFlow() + + private val runTimeMillis: Long + get() = host.runTimeMillis + + suspend fun handleToggleAction() { + if (isRunningRequested()) { + handleStopAction() + } else { + handleStartAction() + } + } + + suspend fun refresh(): Long = transitionLock.withLock { + val current = runTimeMillis + mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED + current + } + + fun captureRequestToken(): RunRequest = arbiter.current() + + /** + * Settles the state after the bound service was lost. [token] is the request that was current + * when the loss was observed, so a start that raced ahead of this callback keeps its intent. + */ + suspend fun handleServiceLost(token: RunRequest) = transitionLock.withLock { + if (runTimeMillis != 0L) { + return@withLock + } + if (!arbiter.resetToStopped(token)) { + return@withLock + } + mutableRunState.value = RunState.STOPPED + } + + suspend fun handleStartAction() { + if (isRunningRequested()) { + return + } + val tile = host.tile() + if (tile != null) { + tile.handleStart() + return + } + loadPreferencesAndStart() + } + + suspend fun handleStopAction() { + if (!isRunningRequested()) { + return + } + val tile = host.tile() + if (tile != null) { + tile.handleStop() + return + } + host.showToast(sharedState.stopTip) + requestStop().await() + } + + suspend fun handleVpnRevokeAction() { + if (!host.isVpnServiceActive()) { + return + } + handleStopAction() + } + + fun requestStart(): Deferred { + val request = createRequest(running = true) + val result = CompletableDeferred() + val launchRequest: (Boolean) -> Unit = { shouldStart -> + if (!shouldStart) { + fail(request) + result.complete(false) + } else { + host.scope.launch { + result.complete( + runCatching { start(request) } + .onFailure { error -> + host.log("Unable to process service start request: $error") + fail(request) + reconcileStopped() + } + .getOrDefault(false), + ) + } + } + } + val app = host.app() + if (app != null) { + app.requestNotificationPermission(launchRequest) + } else { + launchRequest(true) + } + return result + } + + fun requestStop(): Deferred { + val request = createRequest(running = false) + val result = CompletableDeferred() + host.scope.launch { + result.complete( + runCatching { stop(request) } + .onFailure { error -> + host.log("Unable to process service stop request: $error") + } + .getOrDefault(false), + ) + } + return result + } + + fun syncSharedState(state: SharedState) { + sharedState = state + applySharedState() + } + + private suspend fun loadPreferencesAndStart() { + sharedState = host.loadSharedState() + if (sharedState.setupParams == null || sharedState.vpnOptions == null) { + host.showToast(MISSING_CONFIG_MESSAGE) + return + } + if (setupCore()) { + if (!requestStart().await()) { + host.showToast(START_FAILED_MESSAGE) + } + } + } + + private fun applySharedState() { + host.setCrashlytics(sharedState.crashlytics) + host.updateNotificationParams(notificationParams(sharedState)) + } + + private suspend fun setupCore(): Boolean { + applySharedState() + host.showToast(sharedState.startTip) + return host.quickSetup( + initParams(host.homeDirPath, host.sdkInt), + Gson().toJson(sharedState.setupParams), + ).fold( + onSuccess = { message -> + if (message.isEmpty()) { + true + } else { + host.log("Unable to set up core: $message") + showConfigError(message) + false + } + }, + onFailure = { error -> + host.log("Unable to set up core: $error") + showConfigError(error.message) + false + }, + ) + } + + private fun showConfigError(message: String?) { + host.showToast(message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE) + } + + private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock { + val started = runStart(request) + if (!started) { + reconcileStopped() + } + started + } + + private suspend fun runStart(request: RunRequest): Boolean { + if (!isCurrent(request)) { + return false + } + val options = sharedState.vpnOptions + if (options == null) { + fail(request) + return false + } + if (!prepareVpn(options)) { + if (host.app() == null && isCurrent(request)) { + host.showToast(VPN_PERMISSION_MESSAGE) + } + fail(request) + return false + } + if (!isCurrent(request)) { + return false + } + + return transitionLock.withLock transition@{ + if (!isCurrent(request)) { + return@transition false + } + if (runTimeMillis != 0L && host.isVpnServiceActive() == options.enable) { + mutableRunState.value = RunState.STARTED + return@transition true + } + mutableRunState.value = RunState.STARTING + val startedAtMillis = host.startService(options) + if (startedAtMillis == 0L) { + mutableRunState.value = RunState.STOPPED + fail(request) + return@transition false + } + if (!isCurrent(request)) { + return@transition false + } + mutableRunState.value = RunState.STARTED + true + } + } + + private suspend fun reconcileStopped() = transitionLock.withLock { + if (isRunningRequested() || runTimeMillis == 0L) { + return@withLock + } + mutableRunState.value = RunState.STOPPING + host.stopService() + mutableRunState.value = RunState.STOPPED + } + + private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock { + if (!isCurrent(request)) { + return@withLock false + } + abandonVpnPreparation() + if (runState.value == RunState.STOPPED && runTimeMillis == 0L) { + return@withLock true + } + mutableRunState.value = RunState.STOPPING + host.stopService() + mutableRunState.value = RunState.STOPPED + isCurrent(request) + } + + private suspend fun prepareVpn(options: VpnOptions): Boolean { + val app = host.app() + ?: return !options.enable || host.isVpnPermissionGranted() + return suspendCancellableCoroutine { continuation -> + val callback: (Boolean) -> Unit = { granted -> + pendingVpnPreparation = null + if (continuation.isActive) { + continuation.resume(granted) + } + } + pendingVpnPreparation = { + app.cancelVpnPreparation(callback) + callback(false) + } + continuation.invokeOnCancellation { + pendingVpnPreparation = null + app.cancelVpnPreparation(callback) + } + app.prepareVpn(options.enable, callback) + } + } + + private fun abandonVpnPreparation() { + val abandon = pendingVpnPreparation ?: return + pendingVpnPreparation = null + abandon() + } + + private fun createRequest(running: Boolean): RunRequest = arbiter.request(running) + + private fun isRunningRequested(): Boolean = arbiter.isRunningRequested + + private fun isCurrent(request: RunRequest): Boolean = arbiter.isCurrent(request) + + private fun fail(request: RunRequest) { + arbiter.resetToStopped(request) + } + + internal companion object { + /** + * The Core init payload. The key spelling is a cross-language contract with the Go wrapper, + * not an implementation detail. + */ + fun initParams(homeDirPath: String, sdkInt: Int): String = Gson().toJson( + mapOf( + "home-dir" to homeDirPath, + "version" to sdkInt, + ), + ) + + fun notificationParams(state: SharedState): NotificationParams = NotificationParams( + title = state.currentProfileName, + stopText = state.stopText, + onlyStatisticsProxy = state.onlyStatisticsProxy, + ) + } +} diff --git a/android/app/src/main/kotlin/com/follow/clash/packages/ChinaPackageMatcher.kt b/android/app/src/main/kotlin/com/follow/clash/packages/ChinaPackageMatcher.kt new file mode 100644 index 0000000000..ad2ea75539 --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/packages/ChinaPackageMatcher.kt @@ -0,0 +1,92 @@ +package com.follow.clash.packages + +/** + * Decides whether a package name or a fully qualified class name looks like it + * belongs to a domestic app or SDK. + * + * The prefixes in [CHINA_PACKAGE_REGEX] are matched *without* a trailing dot + * boundary, and that is deliberate: `com.qihoo` has to reach `com.qihoo360.*` + * and `com.ali` has to reach `com.aliyun.*` and `com.alimama.*`. Requiring a + * separator would turn those into misses. + * + * The price is that unrelated names starting with the same letters match too. + * [SKIPPED_PREFIXES] is where those come back out, and it *does* apply a dot + * boundary, because there the entries are whole package roots. + */ +internal object ChinaPackageMatcher { + + /** + * Whether [packageName] is never treated as domestic, no matter which + * classes or SDKs it ships. + */ + fun isSkipped(packageName: String): Boolean = SKIPPED_PREFIXES.any { + packageName == it || packageName.startsWith("$it.") + } + + fun matchesKnownPrefix(name: String): Boolean = name.matches(CHINA_PACKAGE_REGEX) + + /** Normalizes a dex type descriptor such as `Lcom/tencent/Foo$Bar;`. */ + fun classNameOf(descriptor: String): String = descriptor + .removeSurrounding("L", ";") + .replace('/', '.') + .replace('$', '.') + + private val SKIPPED_PREFIXES = listOf( + "com.google", + "com.android.chrome", + "com.android.vending", + "com.microsoft", + "com.apple", + "com.zhiliaoapp.musically", + // Caught by the loose "com.mx" prefix, which targets Maxthon's + // com.mx.browser. MX Player is unrelated. + "com.mxtech", + // Caught by the loose "com.stub" prefix, which targets the + // com.stub.StubApp packer. StubHub is unrelated. + "com.stubhub", + ) + + private val CHINA_PACKAGE_REGEX = listOf( + "com.tencent", + "com.alibaba", + "com.umeng", + "com.qihoo", + "com.ali", + "com.alipay", + "com.amap", + "com.sina", + "com.weibo", + "com.vivo", + "com.xiaomi", + "com.huawei", + "com.taobao", + "com.secneo", + "s.h.e.l.l", + "com.stub", + "com.kiwisec", + "com.secshell", + "com.wrapper", + "cn.securitystack", + "com.mogosec", + "com.secoen", + "com.netease", + "com.mx", + "com.qq.e", + "com.baidu", + "com.bytedance", + "com.bugly", + "com.miui", + "com.oppo", + "com.coloros", + "com.iqoo", + "com.meizu", + "com.gionee", + "cn.nubia", + "com.oplus", + "andes.oplus", + "com.unionpay", + "cn.wps", + ).joinToString("|", prefix = "(", postfix = ").*") { prefix -> + Regex.escape(prefix) + }.toRegex() +} diff --git a/android/app/src/main/kotlin/com/follow/clash/packages/PackageResolver.kt b/android/app/src/main/kotlin/com/follow/clash/packages/PackageResolver.kt index 0868553710..c3d6b4fb80 100644 --- a/android/app/src/main/kotlin/com/follow/clash/packages/PackageResolver.kt +++ b/android/app/src/main/kotlin/com/follow/clash/packages/PackageResolver.kt @@ -52,16 +52,16 @@ internal class PackageResolver( } private fun isChinaPackage(packageName: String): Boolean { - if (SKIPPED_PREFIXES.any { packageName == it || packageName.startsWith("$it.") }) { + if (ChinaPackageMatcher.isSkipped(packageName)) { return false } - if (packageName.matches(CHINA_PACKAGE_REGEX)) { + if (ChinaPackageMatcher.matchesKnownPrefix(packageName)) { return true } return runCatching { val packageInfo = getPackageInfo(packageName) - packageInfo.componentNames().any { it.matches(CHINA_PACKAGE_REGEX) } || + packageInfo.componentNames().any(ChinaPackageMatcher::matchesKnownPrefix) || packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true }.getOrDefault(false) } @@ -100,11 +100,9 @@ internal class PackageResolver( DexBackedDexFile.fromInputStream(null, input) } dexFile.classes.any { clazz -> - clazz.type - .removeSurrounding("L", ";") - .replace('/', '.') - .replace('$', '.') - .matches(CHINA_PACKAGE_REGEX) + ChinaPackageMatcher.matchesKnownPrefix( + ChinaPackageMatcher.classNameOf(clazz.type), + ) } } } @@ -117,58 +115,5 @@ internal class PackageResolver( PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - - val SKIPPED_PREFIXES = listOf( - "com.google", - "com.android.chrome", - "com.android.vending", - "com.microsoft", - "com.apple", - "com.zhiliaoapp.musically", - ) - - val CHINA_PACKAGE_REGEX = listOf( - "com.tencent", - "com.alibaba", - "com.umeng", - "com.qihoo", - "com.ali", - "com.alipay", - "com.amap", - "com.sina", - "com.weibo", - "com.vivo", - "com.xiaomi", - "com.huawei", - "com.taobao", - "com.secneo", - "s.h.e.l.l", - "com.stub", - "com.kiwisec", - "com.secshell", - "com.wrapper", - "cn.securitystack", - "com.mogosec", - "com.secoen", - "com.netease", - "com.mx", - "com.qq.e", - "com.baidu", - "com.bytedance", - "com.bugly", - "com.miui", - "com.oppo", - "com.coloros", - "com.iqoo", - "com.meizu", - "com.gionee", - "cn.nubia", - "com.oplus", - "andes.oplus", - "com.unionpay", - "cn.wps", - ).joinToString("|", prefix = "(", postfix = ").*") { prefix -> - Regex.escape(prefix) - }.toRegex() } } diff --git a/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt b/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt index 505d485ee9..8a20432d50 100644 --- a/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt +++ b/android/app/src/main/kotlin/com/follow/clash/plugins/AppPlugin.kt @@ -8,6 +8,8 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.VpnService import android.os.Build +import android.os.Handler +import android.os.Looper import android.os.PowerManager import android.provider.Settings import androidx.core.app.ActivityCompat @@ -20,6 +22,7 @@ import androidx.core.net.toUri import com.follow.clash.R import com.follow.clash.common.Components import com.follow.clash.common.GlobalState +import com.follow.clash.common.PendingCallback import com.follow.clash.common.QuickAction import com.follow.clash.common.quickIntent import com.follow.clash.getPackageIconPath @@ -32,6 +35,7 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -42,13 +46,21 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware private var activity: Activity? = null + private var activityBinding: ActivityPluginBinding? = null + + private val activityResultListener = + PluginRegistry.ActivityResultListener(::onActivityResult) + + private val permissionsResultListener = + PluginRegistry.RequestPermissionsResultListener(::onRequestPermissionsResultListener) + private lateinit var channel: MethodChannel private lateinit var scope: CoroutineScope - private var vpnPrepareCallback: ((Boolean) -> Unit)? = null + private val vpnPrepareCallback = PendingCallback() - private var requestNotificationCallback: ((Boolean) -> Unit)? = null + private val requestNotificationCallback = PendingCallback() private var isRequestingNotificationPermission = false @@ -63,7 +75,30 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware private var skipNotificationPermissionRequest = false - override fun onMethodCall(call: MethodCall, result: Result) { + private val mainHandler = Handler(Looper.getMainLooper()) + + /** + * Runs [block] on the main thread. + * + * The permission and consent hops below touch the Activity — starting an + * activity for result, raising a permission prompt — and read the state that + * tracks whether one is already up. Their callers are coroutines on + * [Dispatchers.Default], while the answers come back on the main thread + * through the ActivityAware listeners, so main is the one thread both ends + * can agree on. A plain main-looper post rather than the plugin scope: the + * scope is cancelled when the engine detaches, and a request dropped there + * would leave its caller waiting for a callback that can no longer run. + */ + private fun onMainThread(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post(block) + } + } + + override fun onMethodCall(call: MethodCall, rawResult: Result) { + val result = MainThreadResult(rawResult) when (call.method) { "moveTaskToBack" -> { activity?.moveTaskToBack(true) @@ -121,7 +156,15 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware } "didCrashOnPreviousExecution" -> { - result.success(GlobalState.didCrashOnPreviousExecution()) + scope.launch(Dispatchers.IO) { + result.success(GlobalState.didCrashOnPreviousExecution()) + } + } + + "getLastExitInfo" -> { + scope.launch(Dispatchers.IO) { + result.success(GlobalState.lastExitInfo()) + } } else -> { @@ -207,9 +250,8 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware task?.setExcludeFromRecents(value ?: false) } - fun requestNotificationPermission(callback: (Boolean) -> Unit) { - requestNotificationCallback?.invoke(false) - requestNotificationCallback = callback + fun requestNotificationPermission(callback: (Boolean) -> Unit) = onMainThread { + requestNotificationCallback.replace(callback, supersededValue = false) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val permission = ContextCompat.checkSelfPermission( GlobalState.application, @@ -217,10 +259,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware ) if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) { invokeRequestNotificationCallback(true) - return + return@onMainThread } if (isRequestingNotificationPermission) { - return + return@onMainThread } isRequestingNotificationPermission = true activity?.let { @@ -230,23 +272,21 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware NOTIFICATION_PERMISSION_REQUEST_CODE, ) } ?: invokeRequestNotificationCallback(true) - return + return@onMainThread } invokeRequestNotificationCallback(true) } private fun invokeRequestNotificationCallback(shouldStart: Boolean) { isRequestingNotificationPermission = false - requestNotificationCallback?.invoke(shouldStart) - requestNotificationCallback = null + requestNotificationCallback.resolve(shouldStart) } - fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) { - invokeVpnPrepareCallback(false) - vpnPrepareCallback = callback + fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) = onMainThread { + vpnPrepareCallback.replace(callback, supersededValue = false) if (!needPrepare) { invokeVpnPrepareCallback(true) - return + return@onMainThread } val intent = VpnService.prepare(GlobalState.application) if (intent != null) { @@ -257,20 +297,19 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware @Suppress("DEPRECATION") activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE) } - return + return@onMainThread } invokeVpnPrepareCallback(true) } - fun cancelVpnPreparation(callback: (Boolean) -> Unit) { - if (vpnPrepareCallback === callback) { - vpnPrepareCallback = null - } + // Posted rather than run where the cancellation lands, so it stays ordered + // behind the prepareVpn that installed the callback it is cancelling. + fun cancelVpnPreparation(callback: (Boolean) -> Unit) = onMainThread { + vpnPrepareCallback.cancel(callback) } private fun invokeVpnPrepareCallback(granted: Boolean) { - vpnPrepareCallback?.invoke(granted) - vpnPrepareCallback = null + vpnPrepareCallback.resolve(granted) } override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { @@ -292,13 +331,23 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware } private fun attachToActivity(binding: ActivityPluginBinding) { + detachFromActivity() + activityBinding = binding activity = binding.activity - binding.addActivityResultListener(::onActivityResult) - binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener) + binding.addActivityResultListener(activityResultListener) + binding.addRequestPermissionsResultListener(permissionsResultListener) } - override fun onDetachedFromActivityForConfigChanges() { + private fun detachFromActivity() { activity = null + val binding = activityBinding ?: return + activityBinding = null + binding.removeActivityResultListener(activityResultListener) + binding.removeRequestPermissionsResultListener(permissionsResultListener) + } + + override fun onDetachedFromActivityForConfigChanges() { + detachFromActivity() } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { @@ -307,7 +356,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware override fun onDetachedFromActivity() { channel.invokeMethod("exit", null) - activity = null + detachFromActivity() invokeVpnPrepareCallback(false) invokeRequestNotificationCallback(false) } diff --git a/android/app/src/main/kotlin/com/follow/clash/plugins/MainThreadResult.kt b/android/app/src/main/kotlin/com/follow/clash/plugins/MainThreadResult.kt new file mode 100644 index 0000000000..d2393946da --- /dev/null +++ b/android/app/src/main/kotlin/com/follow/clash/plugins/MainThreadResult.kt @@ -0,0 +1,37 @@ +package com.follow.clash.plugins + +import android.os.Handler +import android.os.Looper +import io.flutter.plugin.common.MethodChannel + +/** + * Delivers a [MethodChannel.Result] on the main thread, which is where Flutter + * requires it, from handlers that finish on a background dispatcher. + * + * The hop goes through the main looper rather than the plugin's coroutine scope: + * that scope is cancelled when the engine detaches, and a reply dropped by a + * cancelled scope leaves the Dart future waiting forever. A reply posted after + * the engine is gone is the messenger's problem to ignore, and it does. + */ +internal class MainThreadResult( + private val delegate: MethodChannel.Result, +) : MethodChannel.Result { + override fun success(result: Any?) = post { delegate.success(result) } + + override fun error(code: String, message: String?, details: Any?) = + post { delegate.error(code, message, details) } + + override fun notImplemented() = post { delegate.notImplemented() } + + private fun post(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post(block) + } + } + + private companion object { + val mainHandler = Handler(Looper.getMainLooper()) + } +} diff --git a/android/app/src/main/kotlin/com/follow/clash/plugins/ServicePlugin.kt b/android/app/src/main/kotlin/com/follow/clash/plugins/ServicePlugin.kt index 71333b2aff..768357404b 100644 --- a/android/app/src/main/kotlin/com/follow/clash/plugins/ServicePlugin.kt +++ b/android/app/src/main/kotlin/com/follow/clash/plugins/ServicePlugin.kt @@ -31,7 +31,11 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { ServiceController.setEventListener(null) } - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + override fun onMethodCall(call: MethodCall, rawResult: MethodChannel.Result) { + // Most handlers below reply from a scope worker on Dispatchers.Default, + // but a MethodChannel.Result has to be answered on the platform thread. + // Wrapping once here covers every branch, including notImplemented. + val result = MainThreadResult(rawResult) when (call.method) { "init" -> initialize(result) "shutdown" -> shutdown(result) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 6ccdf128ad..18fecbd96b 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -14,6 +14,9 @@ rootProject.layout.buildDirectory.value(newBuildDir) subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) project.layout.buildDirectory.value(newSubprojectBuildDir) +} + +subprojects { project.evaluationDependsOn(":app") } diff --git a/android/common/build.gradle.kts b/android/common/build.gradle.kts index 20d1afdd57..352920b367 100644 --- a/android/common/build.gradle.kts +++ b/android/common/build.gradle.kts @@ -16,6 +16,11 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + + sourceSets { + // Unit tests live under android/tests/ instead of each module's src/test. + getByName("test").java.setSrcDirs(listOf("../tests/common")) + } } kotlin { @@ -30,4 +35,5 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.crashlytics.ndk) implementation(libs.firebase.analytics) + testImplementation(libs.junit) } diff --git a/android/common/src/main/java/com/follow/clash/common/BroadcastLease.kt b/android/common/src/main/java/com/follow/clash/common/BroadcastLease.kt new file mode 100644 index 0000000000..efa6082515 --- /dev/null +++ b/android/common/src/main/java/com/follow/clash/common/BroadcastLease.kt @@ -0,0 +1,37 @@ +package com.follow.clash.common + +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Releases an Android broadcast lease exactly once. + * + * A `BroadcastReceiver.goAsync()` path holds the broadcast open until its + * `PendingResult` is finished, and finishing twice throws. Normal completion and + * the timeout watchdog both race to release it, so the winner is decided here + * rather than by whichever callback happens to run first. + * + * Releasing the lease says only that Android may stop waiting for this receiver. + * It does not cancel, reverse, or otherwise redefine the work the broadcast + * started, which keeps running under its own owner. + */ +class BroadcastLease(private val release: () -> Unit) { + private val released = AtomicBoolean(false) + + val isReleased: Boolean + get() = released.get() + + /** + * Releases the lease if it is still held, running [onRelease] first so a + * caller can report why it won. + * + * Returns whether this call is the one that released it. + */ + fun release(onRelease: () -> Unit = {}): Boolean { + if (!released.compareAndSet(false, true)) { + return false + } + onRelease() + release.invoke() + return true + } +} diff --git a/android/common/src/main/java/com/follow/clash/common/GlobalState.kt b/android/common/src/main/java/com/follow/clash/common/GlobalState.kt index e77973e3db..8e1f00f218 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,6 +1,8 @@ package com.follow.clash.common +import android.app.ActivityManager import android.app.Application +import android.os.Build import android.util.Log import com.google.firebase.FirebaseApp import com.google.firebase.crashlytics.FirebaseCrashlytics @@ -11,6 +13,8 @@ import kotlinx.coroutines.SupervisorJob object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { const val NOTIFICATION_CHANNEL = "FlClash" const val NOTIFICATION_ID = 1 + private const val ANY_PID = 0 + private const val EVERY_EXIT_RECORD = 0 val packageName: String get() = application.packageName @@ -44,4 +48,21 @@ object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatch FirebaseApp.initializeApp(application) return FirebaseCrashlytics.getInstance().didCrashOnPreviousExecution() } + + fun lastExitInfo(): Map? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null + val manager = application.getSystemService(ActivityManager::class.java) ?: return null + val info = runCatching { + manager.getHistoricalProcessExitReasons( + application.packageName, + ANY_PID, + EVERY_EXIT_RECORD, + ) + }.getOrNull()?.firstOrNull { it.processName == application.packageName } ?: return null + return mapOf( + "reason" to info.reason, + "timestamp" to info.timestamp, + "description" to info.description, + ) + } } diff --git a/android/common/src/main/java/com/follow/clash/common/PendingCallback.kt b/android/common/src/main/java/com/follow/clash/common/PendingCallback.kt new file mode 100644 index 0000000000..d1ec3069ad --- /dev/null +++ b/android/common/src/main/java/com/follow/clash/common/PendingCallback.kt @@ -0,0 +1,57 @@ +package com.follow.clash.common + +/** + * A single-slot callback holder for request/response hops that leave the process + * and come back through an Android callback, such as a permission prompt or the + * VPN consent dialog. + * + * Only the newest request is ever pending: [replace] settles the request it + * supersedes rather than dropping it, so no caller is left waiting forever. The + * slot is cleared before the callback runs, so a callback that starts another + * request cannot resolve itself twice. + * + * The two ends of such a hop live on different threads — the request is started + * from whatever coroutine wants the permission, the answer arrives on the main + * thread through `onActivityResult` — so every read and write of the slot is a + * single atomic swap. Without that, a resolve that has already read the slot can + * clear a callback a concurrent replace installed after it, and the request that + * callback belonged to never completes: the VPN consent answer is lost and the + * lock the start path holds is never released. + * + * Callbacks run outside the lock. They resume coroutines and can start the next + * request, and holding the monitor across that would make the slot's lock part + * of every caller's lock order. + */ +class PendingCallback { + private val lock = Any() + private var callback: ((T) -> Unit)? = null + + val isPending: Boolean + get() = synchronized(lock) { callback != null } + + fun replace(next: (T) -> Unit, supersededValue: T) { + val superseded = synchronized(lock) { + val current = callback + callback = next + current + } + superseded?.invoke(supersededValue) + } + + fun resolve(value: T) { + val current = synchronized(lock) { + val current = callback ?: return + callback = null + current + } + current(value) + } + + fun cancel(target: (T) -> Unit) { + synchronized(lock) { + if (callback === target) { + callback = null + } + } + } +} diff --git a/android/common/src/main/java/com/follow/clash/common/RunIntentArbiter.kt b/android/common/src/main/java/com/follow/clash/common/RunIntentArbiter.kt new file mode 100644 index 0000000000..2655b664c5 --- /dev/null +++ b/android/common/src/main/java/com/follow/clash/common/RunIntentArbiter.kt @@ -0,0 +1,30 @@ +package com.follow.clash.common + +import java.util.concurrent.atomic.AtomicReference + +/** + * Tracks the latest requested run intent. + * + * Every request mints a fresh [Token]. Work that was started for an older token is obsolete once a + * newer request arrives, so callers check [isCurrent] before applying a result and roll back with + * [resetToStopped], which only wins while the token is still the latest one. + */ +class RunIntentArbiter(initialRunning: Boolean = false) { + class Token internal constructor( + val running: Boolean, + ) + + private val latest = AtomicReference(Token(initialRunning)) + + val isRunningRequested: Boolean + get() = latest.get().running + + fun current(): Token = latest.get() + + fun request(running: Boolean): Token = Token(running).also(latest::set) + + fun isCurrent(token: Token): Boolean = latest.get() === token + + fun resetToStopped(token: Token): Boolean = + latest.compareAndSet(token, Token(running = false)) +} diff --git a/android/core/src/main/cpp/core.cpp b/android/core/src/main/cpp/core.cpp index 816c75ffcc..52b1a2cbac 100644 --- a/android/core/src/main/cpp/core.cpp +++ b/android/core/src/main/cpp/core.cpp @@ -100,6 +100,7 @@ static void call_tun_interface_protect_impl(void *tun_interface, const int fd) { env->CallVoidMethod(static_cast(tun_interface), m_tun_interface_protect, fd); + jni_clear_exception(env); } static char * @@ -117,8 +118,13 @@ call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol, source_string, target_string, uid)); - env->DeleteLocalRef(source_string); - env->DeleteLocalRef(target_string); + jni_clear_exception(env); + if (source_string != nullptr) { + env->DeleteLocalRef(source_string); + } + if (target_string != nullptr) { + env->DeleteLocalRef(target_string); + } const auto result = get_string(package_name); if (package_name != nullptr) { env->DeleteLocalRef(package_name); @@ -132,7 +138,10 @@ static void call_invoke_interface_result_impl(void *invoke_interface, const char env->CallVoidMethod(static_cast(invoke_interface), m_invoke_interface_result, value); - env->DeleteLocalRef(value); + jni_clear_exception(env); + if (value != nullptr) { + env->DeleteLocalRef(value); + } } extern "C" diff --git a/android/core/src/main/cpp/jni_helper.cpp b/android/core/src/main/cpp/jni_helper.cpp index 1858e35c8e..b4fab311f1 100644 --- a/android/core/src/main/cpp/jni_helper.cpp +++ b/android/core/src/main/cpp/jni_helper.cpp @@ -2,6 +2,7 @@ #include #include +#include static JavaVM *global_vm; @@ -9,33 +10,86 @@ static jclass c_string; static jmethodID m_new_string; static jmethodID m_get_bytes; +static pthread_key_t detach_key; +static bool detach_key_ready; + +static void detach_current_thread(void *) { + global_vm->DetachCurrentThread(); +} + void initialize_jni(JavaVM *vm, JNIEnv *env) { global_vm = vm; + detach_key_ready = pthread_key_create(&detach_key, detach_current_thread) == 0; + c_string = reinterpret_cast(new_global(find_class("java/lang/String"))); m_new_string = find_method(c_string, "", "([B)V"); m_get_bytes = find_method(c_string, "getBytes", "()[B"); } +bool jni_clear_exception(JNIEnv *env) { + if (env->ExceptionCheck() == JNI_FALSE) { + return false; + } + env->ExceptionDescribe(); + env->ExceptionClear(); + return true; +} + +static char *empty_string() { + return static_cast(calloc(1, 1)); +} + char *jni_get_string(JNIEnv *env, jstring str) { if (str == nullptr) { - return static_cast(calloc(1, 1)); + return empty_string(); } const auto array = reinterpret_cast(env->CallObjectMethod(str, m_get_bytes)); + if (jni_clear_exception(env) || array == nullptr) { + return empty_string(); + } const int length = env->GetArrayLength(array); const auto content = static_cast(malloc(length + 1)); + if (content == nullptr) { + env->DeleteLocalRef(array); + return empty_string(); + } env->GetByteArrayRegion(array, 0, length, reinterpret_cast(content)); + if (jni_clear_exception(env)) { + // The copy did not happen, so `content` still holds whatever malloc + // handed back. Returning it would pass that heap content on as the + // string the caller asked for. + free(content); + env->DeleteLocalRef(array); + return empty_string(); + } env->DeleteLocalRef(array); content[length] = 0; return content; } jstring jni_new_string(JNIEnv *env, const char *str) { + if (str == nullptr) { + str = ""; + } const auto length = static_cast(strlen(str)); const auto array = env->NewByteArray(length); + if (jni_clear_exception(env) || array == nullptr) { + return env->NewStringUTF(""); + } env->SetByteArrayRegion(array, 0, length, reinterpret_cast(str)); + if (jni_clear_exception(env)) { + // Calling NewObject with an exception still pending is undefined, and + // the array it would read from was not filled in anyway. + env->DeleteLocalRef(array); + return env->NewStringUTF(""); + } const auto result = reinterpret_cast(env->NewObject(c_string, m_new_string, array)); + jni_clear_exception(env); env->DeleteLocalRef(array); + if (result == nullptr) { + return env->NewStringUTF(""); + } return result; } @@ -44,9 +98,13 @@ void jni_attach_thread(scoped_jni *jni) { jni->require_release = 0; return; } - if (global_vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) { + if (global_vm->AttachCurrentThreadAsDaemon(&jni->env, nullptr) != JNI_OK) { abort(); } + if (detach_key_ready && pthread_setspecific(detach_key, jni->env) == 0) { + jni->require_release = 0; + return; + } jni->require_release = 1; } diff --git a/android/core/src/main/cpp/jni_helper.h b/android/core/src/main/cpp/jni_helper.h index 9d89d9ff0f..90e74b685c 100644 --- a/android/core/src/main/cpp/jni_helper.h +++ b/android/core/src/main/cpp/jni_helper.h @@ -19,6 +19,8 @@ extern void jni_detach_thread(const scoped_jni *env); extern void release_string(char **value); +extern bool jni_clear_exception(JNIEnv *env); + #define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \ scoped_jni _jni{}; \ jni_attach_thread(&_jni); \ diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index c51cabf82c..d6eaaf7b86 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -9,6 +9,8 @@ annotationJvm = "1.9.1" coreSplashscreen = "1.0.1" gson = "2.13.1" smaliDexlib2 = "3.0.9" +junit = "4.13.2" +kotlinxCoroutines = "1.10.2" [libraries] androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } @@ -18,4 +20,6 @@ firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } smali-dexlib2 = { module = "com.android.tools.smali:smali-dexlib2", version.ref = "smaliDexlib2" } diff --git a/android/service/build.gradle.kts b/android/service/build.gradle.kts index 6fe7ad719a..d712c46ab1 100644 --- a/android/service/build.gradle.kts +++ b/android/service/build.gradle.kts @@ -17,6 +17,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + sourceSets { + // Unit tests live under android/tests/ instead of each module's src/test. + getByName("test").java.setSrcDirs(listOf("../tests/service")) + } } kotlin { @@ -30,4 +34,6 @@ dependencies { implementation(project(":common")) implementation(libs.gson) implementation(libs.androidx.core) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/android/service/src/main/java/com/follow/clash/service/VpnService.kt b/android/service/src/main/java/com/follow/clash/service/VpnService.kt index c88f6d0e76..e16a9f49eb 100644 --- a/android/service/src/main/java/com/follow/clash/service/VpnService.kt +++ b/android/service/src/main/java/com/follow/clash/service/VpnService.kt @@ -55,12 +55,14 @@ class VpnService : SystemVpnService(), ManagedService { if (nextUid == -1) { return "" } - return uidPackageNameMap.getOrPut(nextUid) { - packageManager - .getPackagesForUid(nextUid) - ?.firstOrNull() - .orEmpty() - } + val cached = uidPackageNameMap[nextUid] + if (cached != null) return cached + val packageName = packageManager + .getPackagesForUid(nextUid) + ?.firstOrNull() + ?.takeIf { it.isNotEmpty() } + .orEmpty() + return uidPackageNameMap.putIfAbsent(nextUid, packageName) ?: packageName } private val VpnOptions.tunAddress diff --git a/android/service/src/main/java/com/follow/clash/service/modules/NetworkObserveModule.kt b/android/service/src/main/java/com/follow/clash/service/modules/NetworkObserveModule.kt index 153ea6ebe6..c907912136 100644 --- a/android/service/src/main/java/com/follow/clash/service/modules/NetworkObserveModule.kt +++ b/android/service/src/main/java/com/follow/clash/service/modules/NetworkObserveModule.kt @@ -9,6 +9,8 @@ import android.net.NetworkCapabilities.TRANSPORT_SATELLITE import android.net.NetworkCapabilities.TRANSPORT_USB import android.net.NetworkRequest import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.core.content.getSystemService import com.follow.clash.core.Core import java.net.Inet4Address @@ -30,6 +32,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul private val connectivity by lazy { service.getSystemService() } + private val mainHandler = Handler(Looper.getMainLooper()) private var currentDnsList = listOf() private val request = NetworkRequest.Builder().apply { @@ -48,8 +51,16 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul } override fun onLosing(network: Network, maxMsToLive: Int) { - networkInfos[network]?.losingUntilMillis = System.currentTimeMillis() + maxMsToLive + val info = networkInfos[network] ?: return + info.losingUntilMillis = System.currentTimeMillis() + maxMsToLive updateDns() + if (maxMsToLive > 0) { + mainHandler.postDelayed({ + if (networkInfos.containsKey(network)) { + updateDns() + } + }, maxMsToLive.toLong() + 50) + } } override fun onLost(network: Network) { @@ -104,6 +115,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul } override fun stop() { + mainHandler.removeCallbacksAndMessages(null) try { connectivity?.unregisterNetworkCallback(callback) } finally { diff --git a/android/tests/app/ChinaPackageMatcherTest.kt b/android/tests/app/ChinaPackageMatcherTest.kt new file mode 100644 index 0000000000..9936c49416 --- /dev/null +++ b/android/tests/app/ChinaPackageMatcherTest.kt @@ -0,0 +1,130 @@ +package com.follow.clash.packages + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChinaPackageMatcherTest { + + @Test + fun `known vendors and SDKs match`() { + val names = listOf( + "com.tencent.mm", + "com.alipay.android.app", + "com.taobao.taobao", + "com.baidu.searchbox", + "com.bytedance.sdk.openadsdk", + "com.netease.cloudmusic", + "com.unionpay.tsmservice", + "cn.wps.moffice", + "andes.oplus.internal", + ) + for (name in names) { + assertTrue(name, ChinaPackageMatcher.matchesKnownPrefix(name)) + } + } + + @Test + fun `packer signatures used as class names match`() { + val classNames = listOf( + "com.secneo.apkwrapper.H", + "s.h.e.l.l.S", + "com.stub.StubApp", + "com.kiwisec.KiwiSecApplication", + "com.secshell.shellwrapper.SecAppWrapper", + "com.wrapper.proxyapplication.WrapperProxyApplication", + "cn.securitystack.stack.StackApplication", + ) + for (name in classNames) { + assertTrue(name, ChinaPackageMatcher.matchesKnownPrefix(name)) + } + } + + /** + * The prefixes carry no dot boundary on purpose. Adding one would look + * tidier and would silently stop detecting 360 and the Alibaba clouds, + * whose packages extend the prefix without a separator. + */ + @Test + fun `prefixes deliberately match without a separator`() { + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.qihoo360.mobilesafe")) + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.aliyun.linkcard")) + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.alimama.moon")) + } + + @Test + fun `a bare prefix matches on its own`() { + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.tencent")) + } + + @Test + fun `unrelated packages do not match`() { + val names = listOf( + "org.mozilla.firefox", + "com.spotify.music", + "de.telekom.mail", + "com.whatsapp", + ) + for (name in names) { + assertFalse(name, ChinaPackageMatcher.matchesKnownPrefix(name)) + } + } + + /** + * These two do match a prefix, which is exactly why they have to be skipped + * explicitly: MX Player is caught by `com.mx` (meant for Maxthon) and + * StubHub by `com.stub` (meant for the StubApp packer). + */ + @Test + fun `loose prefixes drag in unrelated apps that the skip list removes`() { + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.mxtech.videoplayer.ad")) + assertTrue(ChinaPackageMatcher.isSkipped("com.mxtech.videoplayer.ad")) + + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.stubhub")) + assertTrue(ChinaPackageMatcher.isSkipped("com.stubhub")) + } + + @Test + fun `the intended owners of those prefixes still match and are not skipped`() { + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.mx.browser")) + assertFalse(ChinaPackageMatcher.isSkipped("com.mx.browser")) + + assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.stub.StubApp")) + assertFalse(ChinaPackageMatcher.isSkipped("com.stub.StubApp")) + } + + @Test + fun `the skip list applies a dot boundary`() { + assertTrue(ChinaPackageMatcher.isSkipped("com.google")) + assertTrue(ChinaPackageMatcher.isSkipped("com.google.android.gms")) + assertFalse( + "com.googlefoo is a different vendor", + ChinaPackageMatcher.isSkipped("com.googlefoo"), + ) + } + + @Test + fun `skipping wins over a matching prefix`() { + // TikTok ships domestic SDKs but must stay out of the domestic list. + assertTrue(ChinaPackageMatcher.isSkipped("com.zhiliaoapp.musically")) + } + + @Test + fun `dex descriptors are normalized before matching`() { + assertEquals( + "com.tencent.mm.Foo.Bar", + ChinaPackageMatcher.classNameOf("Lcom/tencent/mm/Foo\$Bar;"), + ) + assertTrue( + ChinaPackageMatcher.matchesKnownPrefix( + ChinaPackageMatcher.classNameOf("Lcom/qihoo360/replugin/Entry;"), + ), + ) + } + + @Test + fun `an already normalized name survives normalization`() { + assertEquals("com.example.Foo", ChinaPackageMatcher.classNameOf("com.example.Foo")) + } +} diff --git a/android/tests/app/ServiceStateMachineTest.kt b/android/tests/app/ServiceStateMachineTest.kt new file mode 100644 index 0000000000..e54acea03f --- /dev/null +++ b/android/tests/app/ServiceStateMachineTest.kt @@ -0,0 +1,649 @@ +package com.follow.clash + +import com.follow.clash.common.AccessControlMode +import com.follow.clash.models.SetupParams +import com.follow.clash.models.SharedState +import com.follow.clash.service.models.AccessControlProps +import com.follow.clash.service.models.NotificationParams +import com.follow.clash.service.models.VpnOptions +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +private fun vpnOptions(enable: Boolean = true) = VpnOptions( + enable = enable, + port = 7890, + ipv6 = false, + dnsHijacking = false, + accessControlProps = AccessControlProps( + enable = false, + mode = AccessControlMode.ACCEPT_SELECTED, + acceptList = emptyList(), + rejectList = emptyList(), + ), + allowBypass = false, + systemProxy = true, + bypassDomain = emptyList(), + stack = "gvisor", + routeAddress = emptyList(), +) + +private fun configuredState(enable: Boolean = true) = SharedState( + vpnOptions = vpnOptions(enable), + setupParams = SetupParams(testUrl = "https://example.com", selectedMap = emptyMap()), +) + +private class FakeTile : TileGateway { + var startCount = 0 + var stopCount = 0 + + override fun handleStart() { + startCount++ + } + + override fun handleStop() { + stopCount++ + } +} + +private class FakeApp( + private val notificationGranted: Boolean = true, + private val vpnGranted: Boolean = true, + private val holdVpnPreparation: Boolean = false, +) : AppGateway { + var beforeVpnPrepared: (() -> Unit)? = null + var cancelledPreparations = 0 + + private var heldCallback: ((Boolean) -> Unit)? = null + + override fun requestNotificationPermission(callback: (Boolean) -> Unit) = + callback(notificationGranted) + + override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) { + beforeVpnPrepared?.invoke() + if (holdVpnPreparation) { + heldCallback = callback + return + } + callback(vpnGranted) + } + + override fun cancelVpnPreparation(callback: (Boolean) -> Unit) { + cancelledPreparations++ + if (heldCallback === callback) { + heldCallback = null + } + } +} + +private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost { + var storedSharedState = configuredState() + var setupResult: Result = Result.success("") + var startResult = 1_700_000_000_000L + var vpnPermissionGranted = true + var vpnServiceActive = true + var tile: TileGateway? = null + var app: AppGateway? = null + var beforeStartService: (() -> Unit)? = null + + override var runTimeMillis = 0L + override val homeDirPath = "/data/user/0/com.follow.clash/files" + override val sdkInt = 34 + + val toasts = mutableListOf() + val logs = mutableListOf() + val notificationParams = mutableListOf() + val crashlytics = mutableListOf() + var setupCalls = 0 + var startCalls = 0 + var stopCalls = 0 + var lastInitParams: String? = null + var lastSetupParams: String? = null + + override fun log(message: String) { + logs += message + } + + override fun showToast(message: String) { + toasts += message + } + + override fun setCrashlytics(enabled: Boolean) { + crashlytics += enabled + } + + override fun updateNotificationParams(params: NotificationParams) { + notificationParams += params + } + + override fun loadSharedState(): SharedState = storedSharedState + + override fun isVpnPermissionGranted(): Boolean = vpnPermissionGranted + + override fun tile(): TileGateway? = tile + + override fun app(): AppGateway? = app + + override suspend fun quickSetup(initParams: String, setupParams: String): Result { + setupCalls++ + lastInitParams = initParams + lastSetupParams = setupParams + return setupResult + } + + override suspend fun startService(options: VpnOptions): Long { + startCalls++ + beforeStartService?.invoke() + runTimeMillis = startResult + return startResult + } + + override suspend fun stopService() { + stopCalls++ + runTimeMillis = 0L + } + + override suspend fun isVpnServiceActive(): Boolean = vpnServiceActive +} + +@OptIn(ExperimentalCoroutinesApi::class) +class ServiceStateMachineTest { + + @Test + fun `initParams spells the keys the Go wrapper expects`() { + val json = ServiceStateMachine.initParams("/files", 34) + + assertEquals("""{"home-dir":"/files","version":34}""", json) + } + + @Test + fun `notification params come straight off the shared state`() { + val params = ServiceStateMachine.notificationParams( + SharedState( + currentProfileName = "Work", + stopText = "Disconnect", + onlyStatisticsProxy = true, + ), + ) + + assertEquals(NotificationParams("Work", "Disconnect", true), params) + } + + @Test + fun `refresh reports STARTED only while the service has a run time`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + assertEquals(0L, machine.refresh()) + assertEquals(RunState.STOPPED, machine.runState.value) + + host.runTimeMillis = 42L + assertEquals(42L, machine.refresh()) + assertEquals(RunState.STARTED, machine.runState.value) + } + + @Test + fun `a start request drives the service to STARTED`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + + assertTrue(machine.requestStart().await()) + assertEquals(RunState.STARTED, machine.runState.value) + assertEquals(1, host.startCalls) + } + + @Test + fun `a start that the service refuses settles back to STOPPED`() = runTest { + val host = FakeHost(backgroundScope) + host.startResult = 0L + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + + assertFalse(machine.requestStart().await()) + assertEquals(RunState.STOPPED, machine.runState.value) + } + + @Test + fun `a start without stored vpn options never reaches the service`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + assertFalse(machine.requestStart().await()) + assertEquals(0, host.startCalls) + } + + @Test + fun `a denied notification permission cancels the start`() = runTest { + val host = FakeHost(backgroundScope) + host.app = FakeApp(notificationGranted = false) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + + assertFalse(machine.requestStart().await()) + assertEquals(0, host.startCalls) + assertEquals(RunState.STOPPED, machine.runState.value) + } + + @Test + fun `a denied vpn permission cancels the start`() = runTest { + val host = FakeHost(backgroundScope) + host.app = FakeApp(vpnGranted = false) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + + assertFalse(machine.requestStart().await()) + assertEquals(0, host.startCalls) + } + + /** + * Without a foreground app there is nobody to show the system consent dialog, so the machine + * has to explain the refusal itself. + */ + @Test + fun `a missing vpn permission is reported when no app is attached`() = runTest { + val host = FakeHost(backgroundScope) + host.vpnPermissionGranted = false + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + + assertFalse(machine.requestStart().await()) + assertTrue(host.toasts.contains(VPN_PERMISSION_MESSAGE)) + } + + @Test + fun `a proxy-only start does not need the vpn permission`() = runTest { + val host = FakeHost(backgroundScope) + host.vpnPermissionGranted = false + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState(enable = false)) + + assertTrue(machine.requestStart().await()) + assertEquals(1, host.startCalls) + } + + @Test + fun `a stop request drives the service to STOPPED`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + + assertTrue(machine.requestStop().await()) + assertEquals(RunState.STOPPED, machine.runState.value) + assertEquals(1, host.stopCalls) + } + + @Test + fun `stopping an already stopped service touches nothing`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + assertTrue(machine.requestStop().await()) + assertEquals(0, host.stopCalls) + } + + @Test + fun `a stop that lands mid-preparation keeps the service stopped`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + val app = FakeApp() + host.app = app + app.beforeVpnPrepared = { machine.requestStop() } + + assertFalse(machine.requestStart().await()) + assertEquals(0, host.startCalls) + assertEquals(RunState.STOPPED, machine.runState.value) + } + + @Test + fun `a start reports failure when a stop overtakes the service call`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + host.beforeStartService = { machine.requestStop() } + + assertFalse(machine.requestStart().await()) + } + + @Test + fun `handleServiceLost clears the state for the token that observed the loss`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + + val token = machine.captureRequestToken() + host.runTimeMillis = 0L + machine.handleServiceLost(token) + + assertEquals(RunState.STOPPED, machine.runState.value) + } + + @Test + fun `handleServiceLost keeps the intent of a start that raced ahead of it`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + val staleToken = machine.captureRequestToken() + + machine.requestStart().await() + host.runTimeMillis = 0L + machine.handleServiceLost(staleToken) + + assertEquals(RunState.STARTED, machine.runState.value) + } + + @Test + fun `handleServiceLost ignores a service that is running again`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + + machine.handleServiceLost(machine.captureRequestToken()) + + assertEquals(RunState.STARTED, machine.runState.value) + } + + @Test + fun `handleStartAction hands the start to the tile when one is attached`() = runTest { + val host = FakeHost(backgroundScope) + val tile = FakeTile() + host.tile = tile + val machine = ServiceStateMachine(host) + + machine.handleStartAction() + + assertEquals(1, tile.startCount) + assertEquals(0, host.setupCalls) + } + + @Test + fun `handleStartAction is a no-op while a run is already requested`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + val startsBefore = host.startCalls + + machine.handleStartAction() + + assertEquals(startsBefore, host.startCalls) + assertEquals(0, host.setupCalls) + } + + @Test + fun `handleStartAction reports a missing configuration`() = runTest { + val host = FakeHost(backgroundScope) + host.storedSharedState = SharedState() + val machine = ServiceStateMachine(host) + + machine.handleStartAction() + + assertEquals(listOf(MISSING_CONFIG_MESSAGE), host.toasts) + assertEquals(0, host.setupCalls) + } + + @Test + fun `handleStartAction loads the stored configuration and sets the core up`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + machine.handleStartAction() + + assertEquals(1, host.setupCalls) + assertEquals(1, host.startCalls) + assertEquals(RunState.STARTED, machine.runState.value) + assertEquals( + ServiceStateMachine.initParams(host.homeDirPath, host.sdkInt), + host.lastInitParams, + ) + assertEquals(Gson().toJson(host.storedSharedState.setupParams), host.lastSetupParams) + } + + @Test + fun `a core that rejects the configuration reports its own message`() = runTest { + val host = FakeHost(backgroundScope) + host.setupResult = Result.success("proxy group not found") + val machine = ServiceStateMachine(host) + + machine.handleStartAction() + + assertTrue(host.toasts.contains("proxy group not found")) + assertEquals(0, host.startCalls) + } + + @Test + fun `a core setup that throws without a message falls back to the generic one`() = runTest { + val host = FakeHost(backgroundScope) + host.setupResult = Result.failure(RuntimeException(" ")) + val machine = ServiceStateMachine(host) + + machine.handleStartAction() + + assertTrue(host.toasts.contains(INVALID_CONFIG_MESSAGE)) + assertEquals(0, host.startCalls) + } + + @Test + fun `a service that refuses the start after a good setup is reported`() = runTest { + val host = FakeHost(backgroundScope) + host.startResult = 0L + val machine = ServiceStateMachine(host) + + machine.handleStartAction() + + assertTrue(host.toasts.contains(START_FAILED_MESSAGE)) + } + + @Test + fun `handleStopAction hands the stop to the tile when one is attached`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + val tile = FakeTile() + host.tile = tile + + machine.handleStopAction() + + assertEquals(1, tile.stopCount) + assertEquals(0, host.stopCalls) + } + + @Test + fun `handleStopAction is a no-op while nothing is running`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + machine.handleStopAction() + + assertEquals(0, host.stopCalls) + assertTrue(host.toasts.isEmpty()) + } + + @Test + fun `handleStopAction announces itself before stopping`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState().copy(stopTip = "Stopping...")) + machine.requestStart().await() + + machine.handleStopAction() + + assertEquals(listOf("Stopping..."), host.toasts) + assertEquals(1, host.stopCalls) + } + + @Test + fun `handleToggleAction starts when stopped and stops when started`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + machine.handleToggleAction() + assertEquals(1, host.startCalls) + + machine.handleToggleAction() + assertEquals(1, host.stopCalls) + } + + @Test + fun `a revoke is ignored while no vpn service is active`() = runTest { + val host = FakeHost(backgroundScope) + host.vpnServiceActive = false + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + + machine.handleVpnRevokeAction() + + assertEquals(0, host.stopCalls) + } + + @Test + fun `a revoke stops the active vpn service`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + machine.requestStart().await() + + machine.handleVpnRevokeAction() + + assertEquals(1, host.stopCalls) + } + + @Test + fun `syncSharedState pushes crashlytics and the notification straight through`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + + machine.syncSharedState( + SharedState( + crashlytics = false, + currentProfileName = "Work", + stopText = "Disconnect", + onlyStatisticsProxy = true, + ), + ) + + assertEquals(listOf(false), host.crashlytics) + assertEquals(listOf(NotificationParams("Work", "Disconnect", true)), host.notificationParams) + } + + @Test + fun `a start that a stop overtakes never announces STARTED`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + val states = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + machine.runState.toList(states) + } + host.beforeStartService = { machine.requestStop() } + + assertFalse(machine.requestStart().await()) + testScheduler.runCurrent() + + assertFalse(states.contains(RunState.STARTED)) + assertEquals(RunState.STOPPED, machine.runState.value) + assertEquals(0L, host.runTimeMillis) + } + + @Test + fun `a start rolled back after the service came up stops it again`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + host.beforeStartService = { + host.app = FakeApp(vpnGranted = false) + machine.requestStart() + } + + assertFalse(machine.requestStart().await()) + testScheduler.runCurrent() + + assertEquals(1, host.stopCalls) + assertEquals(0L, host.runTimeMillis) + assertEquals(RunState.STOPPED, machine.runState.value) + assertFalse(machine.captureRequestToken().running) + } + + @Test + fun `a start that overtakes another inside the binding window adopts the running service`() = + runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + host.beforeStartService = { + host.beforeStartService = null + machine.requestStart() + } + + machine.requestStart().await() + testScheduler.runCurrent() + + assertEquals(1, host.startCalls) + assertEquals(0, host.stopCalls) + assertEquals(RunState.STARTED, machine.runState.value) + assertTrue(machine.captureRequestToken().running) + } + + @Test + fun `a start rebinds when the running service is not the one the options ask for`() = runTest { + val host = FakeHost(backgroundScope) + host.vpnServiceActive = false + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + host.beforeStartService = { + host.beforeStartService = null + machine.requestStart() + } + + machine.requestStart().await() + testScheduler.runCurrent() + + assertEquals(2, host.startCalls) + assertEquals(RunState.STARTED, machine.runState.value) + } + + @Test + fun `a stop releases a start that is waiting on the vpn consent`() = runTest { + val host = FakeHost(backgroundScope) + val app = FakeApp(holdVpnPreparation = true) + host.app = app + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + + val start = machine.requestStart() + testScheduler.runCurrent() + assertEquals(0, host.startCalls) + + assertTrue(machine.requestStop().await()) + assertFalse(start.await()) + assertEquals(0, host.startCalls) + assertEquals(1, app.cancelledPreparations) + assertEquals(RunState.STOPPED, machine.runState.value) + } + + @Test + fun `a start request that throws is logged and rolled back`() = runTest { + val host = FakeHost(backgroundScope) + val machine = ServiceStateMachine(host) + machine.syncSharedState(configuredState()) + host.beforeStartService = { throw IllegalStateException("binder died") } + + assertFalse(machine.requestStart().await()) + assertTrue(host.logs.any { it.contains("binder died") }) + assertFalse(machine.captureRequestToken().running) + } +} diff --git a/android/tests/app/SharedStateTest.kt b/android/tests/app/SharedStateTest.kt new file mode 100644 index 0000000000..4fc739b00f --- /dev/null +++ b/android/tests/app/SharedStateTest.kt @@ -0,0 +1,155 @@ +package com.follow.clash.models + +import com.follow.clash.common.AccessControlMode +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * `SharedState` is written by Flutter into `FlutterSharedPreferences` and read back here, so the + * JSON spelling of every field is a cross-language contract rather than an implementation detail. + */ +class SharedStateTest { + private val gson = Gson() + + @Test + fun `setup params use the kebab-case spelling Flutter writes`() { + val json = """{"test-url":"https://example.test/204","selected-map":{"GLOBAL":"auto"}}""" + + val params = gson.fromJson(json, SetupParams::class.java) + + assertEquals("https://example.test/204", params.testUrl) + assertEquals(mapOf("GLOBAL" to "auto"), params.selectedMap) + } + + @Test + fun `setup params serialize back to the same spelling`() { + val encoded = gson.toJson( + SetupParams(testUrl = "https://example.test", selectedMap = emptyMap()), + ) + + assertTrue(encoded.contains("\"test-url\"")) + assertTrue(encoded.contains("\"selected-map\"")) + assertTrue(!encoded.contains("testUrl")) + assertTrue(!encoded.contains("selectedMap")) + } + + @Test + fun `a full payload round-trips including nested vpn options`() { + val json = """ + { + "startTip": "Starting", + "stopTip": "Stopping", + "crashlytics": false, + "currentProfileName": "Work", + "stopText": "Halt", + "onlyStatisticsProxy": true, + "vpnOptions": { + "enable": true, + "port": 7890, + "ipv6": false, + "dnsHijacking": true, + "accessControlProps": { + "enable": true, + "mode": "rejectSelected", + "acceptList": ["a.b"], + "rejectList": ["c.d"] + }, + "allowBypass": false, + "systemProxy": true, + "bypassDomain": ["example.test"], + "stack": "gvisor", + "routeAddress": ["0.0.0.0/0"] + }, + "setupParams": { + "test-url": "https://example.test/204", + "selected-map": {"GLOBAL": "auto"} + } + } + """.trimIndent() + + val state = gson.fromJson(json, SharedState::class.java) + + assertEquals("Starting", state.startTip) + assertEquals("Work", state.currentProfileName) + assertEquals(false, state.crashlytics) + assertEquals(true, state.onlyStatisticsProxy) + assertEquals(7890, state.vpnOptions?.port) + assertEquals("gvisor", state.vpnOptions?.stack) + assertEquals( + AccessControlMode.REJECT_SELECTED, + state.vpnOptions?.accessControlProps?.mode, + ) + assertEquals(listOf("0.0.0.0/0"), state.vpnOptions?.routeAddress) + assertEquals("https://example.test/204", state.setupParams?.testUrl) + } + + @Test + fun `the constructed default keeps every fallback Flutter relies on`() { + val defaults = SharedState() + + assertEquals("FlClash", defaults.currentProfileName) + assertEquals("Stop", defaults.stopText) + assertEquals(true, defaults.crashlytics) + assertEquals(false, defaults.onlyStatisticsProxy) + assertNull(defaults.vpnOptions) + assertNull(defaults.setupParams) + } + + @Test + fun `an empty document still yields the declared defaults`() { + // Every SharedState parameter has a default, so Kotlin emits a no-arg constructor + // that Gson uses instead of its Unsafe fallback. Losing a default on any one + // parameter would silently turn the absent fields below into nulls. + val state = gson.fromJson("{}", SharedState::class.java) + + assertNotNull(state) + assertEquals("Starting VPN...", state.startTip) + assertEquals("FlClash", state.currentProfileName) + assertEquals(true, state.crashlytics) + assertNull(state.vpnOptions) + assertNull(state.setupParams) + } + + @Test + fun `a partial document keeps defaults for the fields it omits`() { + val state = gson.fromJson("""{"currentProfileName":"Work"}""", SharedState::class.java) + + assertEquals("Work", state.currentProfileName) + assertEquals("Stop", state.stopText) + assertEquals("Stopping VPN...", state.stopTip) + } + + @Test + fun `setup params have no defaults so absent fields decode as null`() { + // SetupParams declares no default values, so Gson builds it through Unsafe and + // leaves missing fields null despite the non-nullable Kotlin types. + val params = gson.fromJson("{}", SetupParams::class.java) + + @Suppress("SENSELESS_COMPARISON") + assertTrue(params.testUrl == null) + + @Suppress("SENSELESS_COMPARISON") + assertTrue(params.selectedMap == null) + } + + @Test + fun `a null json document decodes to null so the caller can fall back`() { + assertNull(gson.fromJson("null", SharedState::class.java)) + assertNull(gson.fromJson(null as String?, SharedState::class.java)) + } + + @Test + fun `an access control payload without a matching mode decodes to null`() { + val json = """ + {"vpnOptions":{"accessControlProps":{"mode":"unknownMode"}}} + """.trimIndent() + + val state = gson.fromJson(json, SharedState::class.java) + + assertNull(state.vpnOptions?.accessControlProps?.mode) + } +} diff --git a/android/tests/common/BroadcastLeaseTest.kt b/android/tests/common/BroadcastLeaseTest.kt new file mode 100644 index 0000000000..3732c4c83c --- /dev/null +++ b/android/tests/common/BroadcastLeaseTest.kt @@ -0,0 +1,87 @@ +package com.follow.clash.common + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BroadcastLeaseTest { + @Test + fun `starts held`() { + val lease = BroadcastLease { } + + assertFalse(lease.isReleased) + } + + @Test + fun `the first release runs the release action and reports the win`() { + var releases = 0 + val lease = BroadcastLease { releases++ } + + assertTrue(lease.release()) + assertEquals(1, releases) + assertTrue(lease.isReleased) + } + + @Test + fun `a second release neither runs nor claims the win`() { + var releases = 0 + val lease = BroadcastLease { releases++ } + lease.release() + + assertFalse(lease.release()) + assertEquals(1, releases) + } + + @Test + fun `the reason runs only for the caller that wins`() { + val reasons = mutableListOf() + val lease = BroadcastLease { } + + lease.release { reasons.add("timeout") } + lease.release { reasons.add("completion") } + + assertEquals(listOf("timeout"), reasons) + } + + @Test + fun `the reason runs before the lease is released`() { + val order = mutableListOf() + val lease = BroadcastLease { order.add("release") } + + lease.release { order.add("reason") } + + assertEquals(listOf("reason", "release"), order) + } + + @Test + fun `only one of many concurrent releases wins`() { + val threads = 16 + val releases = AtomicInteger(0) + val wins = AtomicInteger(0) + val lease = BroadcastLease { releases.incrementAndGet() } + val start = CountDownLatch(1) + val done = CountDownLatch(threads) + val pool = Executors.newFixedThreadPool(threads) + + repeat(threads) { + pool.execute { + start.await() + if (lease.release()) { + wins.incrementAndGet() + } + done.countDown() + } + } + start.countDown() + + assertTrue(done.await(5, TimeUnit.SECONDS)) + pool.shutdown() + assertEquals(1, releases.get()) + assertEquals(1, wins.get()) + } +} diff --git a/android/tests/common/EnumsTest.kt b/android/tests/common/EnumsTest.kt new file mode 100644 index 0000000000..96889b66ad --- /dev/null +++ b/android/tests/common/EnumsTest.kt @@ -0,0 +1,47 @@ +package com.follow.clash.common + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The Flutter layer exchanges these enums as JSON, so the serialized spelling is a cross-language + * contract rather than an implementation detail. + */ +class EnumsTest { + private val gson = Gson() + + @Test + fun `access control mode serializes to the Dart spelling`() { + assertEquals("\"acceptSelected\"", gson.toJson(AccessControlMode.ACCEPT_SELECTED)) + assertEquals("\"rejectSelected\"", gson.toJson(AccessControlMode.REJECT_SELECTED)) + } + + @Test + fun `access control mode deserializes from the Dart spelling`() { + assertEquals( + AccessControlMode.ACCEPT_SELECTED, + gson.fromJson("\"acceptSelected\"", AccessControlMode::class.java), + ) + assertEquals( + AccessControlMode.REJECT_SELECTED, + gson.fromJson("\"rejectSelected\"", AccessControlMode::class.java), + ) + } + + @Test + fun `quick action names back the intent action suffixes`() { + assertEquals( + listOf("STOP", "START", "TOGGLE"), + QuickAction.entries.map { it.name }, + ) + } + + @Test + fun `broadcast action names back the intent action suffixes`() { + assertEquals( + listOf("VPN_START_REQUESTED", "VPN_REVOKED"), + BroadcastAction.entries.map { it.name }, + ) + } +} diff --git a/android/tests/common/PendingCallbackTest.kt b/android/tests/common/PendingCallbackTest.kt new file mode 100644 index 0000000000..60340c5429 --- /dev/null +++ b/android/tests/common/PendingCallbackTest.kt @@ -0,0 +1,164 @@ +package com.follow.clash.common + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PendingCallbackTest { + @Test + fun `starts empty`() { + val slot = PendingCallback() + + assertFalse(slot.isPending) + } + + @Test + fun `replace leaves the new callback pending`() { + val slot = PendingCallback() + + slot.replace({ }, supersededValue = false) + + assertTrue(slot.isPending) + } + + @Test + fun `resolve delivers the value once and empties the slot`() { + val slot = PendingCallback() + val received = mutableListOf() + slot.replace({ received.add(it) }, supersededValue = false) + + slot.resolve(true) + slot.resolve(true) + + assertEquals(listOf(true), received) + assertFalse(slot.isPending) + } + + @Test + fun `replace settles the request it supersedes`() { + val slot = PendingCallback() + val first = mutableListOf() + val second = mutableListOf() + slot.replace({ first.add(it) }, supersededValue = false) + + slot.replace({ second.add(it) }, supersededValue = false) + + assertEquals(listOf(false), first) + assertTrue(second.isEmpty()) + + slot.resolve(true) + + assertEquals(listOf(false), first) + assertEquals(listOf(true), second) + } + + @Test + fun `resolve on an empty slot is inert`() { + val slot = PendingCallback() + + slot.resolve(true) + + assertFalse(slot.isPending) + } + + @Test + fun `cancel drops the matching callback without invoking it`() { + val slot = PendingCallback() + val received = mutableListOf() + val callback: (Boolean) -> Unit = { received.add(it) } + slot.replace(callback, supersededValue = false) + + slot.cancel(callback) + + assertFalse(slot.isPending) + assertTrue(received.isEmpty()) + } + + @Test + fun `cancel keeps a callback that is no longer the pending one`() { + val slot = PendingCallback() + val stale = mutableListOf() + val current = mutableListOf() + val staleCallback: (Boolean) -> Unit = { stale.add(it) } + slot.replace(staleCallback, supersededValue = false) + slot.replace({ current.add(it) }, supersededValue = false) + + slot.cancel(staleCallback) + + assertTrue(slot.isPending) + + slot.resolve(true) + + assertEquals(listOf(false), stale) + assertEquals(listOf(true), current) + } + + @Test + fun `a callback that starts a new request is not resolved twice`() { + val slot = PendingCallback() + val outer = mutableListOf() + val inner = mutableListOf() + slot.replace( + { + outer.add(it) + slot.replace({ value -> inner.add(value) }, supersededValue = false) + }, + supersededValue = false, + ) + + slot.resolve(true) + + assertEquals(listOf(true), outer) + assertTrue(inner.isEmpty()) + assertTrue(slot.isPending) + + slot.resolve(false) + + assertEquals(listOf(true), outer) + assertEquals(listOf(false), inner) + } + + /** + * The request is started off the main thread and the answer arrives on it, so + * a resolve that has already read the slot must not clear the callback a + * concurrent replace installed after that read. Losing it strands the request + * that callback belongs to, which on the VPN consent hop means the start path + * never releases its lock. + */ + @Test + fun `a callback installed while a resolve is in flight is never dropped`() { + repeat(200) { + val slot = PendingCallback() + val settled = AtomicInteger() + slot.replace({ settled.incrementAndGet() }, supersededValue = false) + + val ready = CountDownLatch(2) + val go = CountDownLatch(1) + val replacer = Thread { + ready.countDown() + go.await() + slot.replace({ settled.incrementAndGet() }, supersededValue = false) + } + val resolver = Thread { + ready.countDown() + go.await() + slot.resolve(true) + } + replacer.start() + resolver.start() + ready.await() + go.countDown() + replacer.join(TimeUnit.SECONDS.toMillis(5)) + resolver.join(TimeUnit.SECONDS.toMillis(5)) + + // Two callbacks were installed and at most one of them can still be + // pending, so every callback the slot let go of must have been called. + val pending = if (slot.isPending) 1 else 0 + assertEquals(2 - pending, settled.get()) + } + } +} diff --git a/android/tests/common/RunIntentArbiterTest.kt b/android/tests/common/RunIntentArbiterTest.kt new file mode 100644 index 0000000000..fb34c38482 --- /dev/null +++ b/android/tests/common/RunIntentArbiterTest.kt @@ -0,0 +1,137 @@ +package com.follow.clash.common + +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class RunIntentArbiterTest { + @Test + fun `starts stopped by default`() { + val arbiter = RunIntentArbiter() + + assertFalse(arbiter.isRunningRequested) + assertFalse(arbiter.current().running) + } + + @Test + fun `request publishes the new intent`() { + val arbiter = RunIntentArbiter() + + val token = arbiter.request(running = true) + + assertTrue(arbiter.isRunningRequested) + assertTrue(arbiter.isCurrent(token)) + assertSame(token, arbiter.current()) + } + + @Test + fun `each request mints a distinct token`() { + val arbiter = RunIntentArbiter() + + val first = arbiter.request(running = true) + val second = arbiter.request(running = true) + + assertNotSame(first, second) + assertFalse(arbiter.isCurrent(first)) + assertTrue(arbiter.isCurrent(second)) + } + + @Test + fun `a newer request supersedes work started for an older token`() { + val arbiter = RunIntentArbiter() + val start = arbiter.request(running = true) + + val stop = arbiter.request(running = false) + + assertFalse(arbiter.isCurrent(start)) + assertTrue(arbiter.isCurrent(stop)) + assertFalse(arbiter.isRunningRequested) + } + + @Test + fun `resetToStopped rolls back the current token`() { + val arbiter = RunIntentArbiter() + val token = arbiter.request(running = true) + + assertTrue(arbiter.resetToStopped(token)) + + assertFalse(arbiter.isRunningRequested) + assertFalse(arbiter.isCurrent(token)) + } + + @Test + fun `resetToStopped does not clobber a newer intent`() { + val arbiter = RunIntentArbiter() + val stale = arbiter.request(running = true) + val latest = arbiter.request(running = true) + + assertFalse(arbiter.resetToStopped(stale)) + + assertTrue(arbiter.isRunningRequested) + assertTrue(arbiter.isCurrent(latest)) + } + + @Test + fun `resetToStopped is not idempotent for the same token`() { + val arbiter = RunIntentArbiter() + val token = arbiter.request(running = true) + + assertTrue(arbiter.resetToStopped(token)) + assertFalse(arbiter.resetToStopped(token)) + } + + @Test + fun `a start racing ahead of a service-lost callback keeps its intent`() { + val arbiter = RunIntentArbiter() + val observed = arbiter.request(running = true) + + // The service-loss callback captured `observed`, but a new start won the race first. + val restart = arbiter.request(running = true) + val settled = arbiter.resetToStopped(observed) + + assertFalse(settled) + assertTrue(arbiter.isRunningRequested) + assertTrue(arbiter.isCurrent(restart)) + } + + @Test + fun `initialRunning seeds the first intent`() { + val arbiter = RunIntentArbiter(initialRunning = true) + + assertTrue(arbiter.isRunningRequested) + } + + @Test + fun `concurrent requests leave exactly one winning token`() { + val arbiter = RunIntentArbiter() + val threads = 8 + val executor = Executors.newFixedThreadPool(threads) + val barrier = CyclicBarrier(threads) + + try { + val tokens = (0 until threads).map { index -> + executor.submit { + barrier.await(5, TimeUnit.SECONDS) + arbiter.request(running = index % 2 == 0) + } + }.map { it.get(5, TimeUnit.SECONDS) } + + val winners = tokens.filter(arbiter::isCurrent) + assertEquals(1, winners.size) + assertEquals(winners.single().running, arbiter.isRunningRequested) + + tokens.filterNot(arbiter::isCurrent).forEach { stale -> + assertFalse(arbiter.resetToStopped(stale)) + } + assertTrue(arbiter.isCurrent(winners.single())) + } finally { + executor.shutdownNow() + } + } +} diff --git a/android/tests/service/ServiceConfigTest.kt b/android/tests/service/ServiceConfigTest.kt new file mode 100644 index 0000000000..3db2a7c753 --- /dev/null +++ b/android/tests/service/ServiceConfigTest.kt @@ -0,0 +1,76 @@ +package com.follow.clash.service + +import com.follow.clash.common.AccessControlMode +import com.follow.clash.service.models.AccessControlProps +import com.follow.clash.service.models.NotificationParams +import com.follow.clash.service.models.VpnOptions +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Test + +private fun vpnOptions(port: Int) = VpnOptions( + enable = true, + port = port, + ipv6 = false, + dnsHijacking = true, + accessControlProps = AccessControlProps( + enable = true, + mode = AccessControlMode.REJECT_SELECTED, + acceptList = listOf("com.example.accepted"), + rejectList = listOf("com.example.rejected"), + ), + allowBypass = true, + systemProxy = false, + bypassDomain = listOf("example.test"), + stack = "system", + routeAddress = listOf("0.0.0.0/0"), +) + +class ServiceConfigTest { + @Test + fun `notification params default to the app name and stop label`() { + val defaults = NotificationParams() + + assertEquals("FlClash", defaults.title) + assertEquals("STOP", defaults.stopText) + assertEquals(false, defaults.onlyStatisticsProxy) + } + + @Test + fun `updateVpnOptions publishes the latest options`() { + ServiceConfig.updateVpnOptions(vpnOptions(7890)) + assertEquals(7890, ServiceConfig.vpnOptions?.port) + + val latest = vpnOptions(7891) + ServiceConfig.updateVpnOptions(latest) + + assertSame(latest, ServiceConfig.vpnOptions) + } + + @Test + fun `updateNotificationParams emits through the state flow`() = runTest { + val params = NotificationParams( + title = "Profile", + stopText = "Halt", + onlyStatisticsProxy = true, + ) + + ServiceConfig.updateNotificationParams(params) + + assertSame(params, ServiceConfig.notificationParams.value) + } + + @Test + fun `notification params state flow keeps the newest value`() = runTest { + val first = NotificationParams(title = "first") + val second = NotificationParams(title = "second") + + ServiceConfig.updateNotificationParams(first) + ServiceConfig.updateNotificationParams(second) + + assertSame(second, ServiceConfig.notificationParams.value) + assertNotSame(first, ServiceConfig.notificationParams.value) + } +} diff --git a/android/tests/service/TrafficTest.kt b/android/tests/service/TrafficTest.kt new file mode 100644 index 0000000000..b0de11ea8b --- /dev/null +++ b/android/tests/service/TrafficTest.kt @@ -0,0 +1,67 @@ +package com.follow.clash.service.models + +import java.util.Locale +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TrafficTest { + private lateinit var defaultLocale: Locale + + @Before + fun pinLocale() { + // speedText formats through String.format, which follows the default locale. + defaultLocale = Locale.getDefault() + Locale.setDefault(Locale.ROOT) + } + + @After + fun restoreLocale() { + Locale.setDefault(defaultLocale) + } + + @Test + fun `renders whole bytes without a decimal part`() { + assertEquals("0B/s↑ 0B/s↓", Traffic(up = 0, down = 0).speedText) + assertEquals("1023B/s↑ 1B/s↓", Traffic(up = 1023, down = 1).speedText) + } + + @Test + fun `promotes to the next unit at 1024`() { + assertEquals("1.0KB/s↑ 1.0KB/s↓", Traffic(up = 1024, down = 1024).speedText) + } + + @Test + fun `keeps one decimal place for fractional values`() { + assertEquals("1.5KB/s↑ 2.5KB/s↓", Traffic(up = 1536, down = 2560).speedText) + } + + @Test + fun `scales through every unit`() { + assertTrue(Traffic(up = 1024L * 1024, down = 0).speedText.startsWith("1.0MB")) + assertTrue( + Traffic(up = 1024L * 1024 * 1024, down = 0).speedText.startsWith("1.0GB"), + ) + assertTrue( + Traffic(up = 1024L * 1024 * 1024 * 1024, down = 0).speedText + .startsWith("1.0TB"), + ) + } + + @Test + fun `stops promoting above terabytes`() { + val text = Traffic(up = 1024L * 1024 * 1024 * 1024 * 1024, down = 0).speedText + + assertTrue(text.startsWith("1024.0TB")) + } + + @Test + fun `labels upload before download`() { + val text = Traffic(up = 1, down = 2).speedText + + assertTrue(text.indexOf("↑") < text.indexOf("↓")) + assertEquals("1B/s↑ 2B/s↓", text) + } +} diff --git a/android/tests/service/VpnOptionsTest.kt b/android/tests/service/VpnOptionsTest.kt new file mode 100644 index 0000000000..e3bafd6595 --- /dev/null +++ b/android/tests/service/VpnOptionsTest.kt @@ -0,0 +1,144 @@ +package com.follow.clash.service.models + +import com.follow.clash.common.AccessControlMode +import java.net.Inet4Address +import java.net.Inet6Address +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +private fun optionsWithRoutes(routeAddress: List) = VpnOptions( + enable = true, + port = 7890, + ipv6 = true, + dnsHijacking = false, + accessControlProps = AccessControlProps( + enable = false, + mode = AccessControlMode.ACCEPT_SELECTED, + acceptList = emptyList(), + rejectList = emptyList(), + ), + allowBypass = false, + systemProxy = true, + bypassDomain = emptyList(), + stack = "gvisor", + routeAddress = routeAddress, +) + +class ToCIDRTest { + @Test + fun `parses an IPv4 network`() { + val cidr = "192.168.1.0/24".toCIDR() + + assertTrue(cidr.address is Inet4Address) + assertEquals("192.168.1.0", cidr.address.hostAddress) + assertEquals(24, cidr.prefixLength) + } + + @Test + fun `parses an IPv6 network`() { + val cidr = "fd00::/8".toCIDR() + + assertTrue(cidr.address is Inet6Address) + assertEquals(8, cidr.prefixLength) + } + + @Test + fun `accepts the boundary prefix lengths`() { + assertEquals(0, "0.0.0.0/0".toCIDR().prefixLength) + assertEquals(32, "10.0.0.1/32".toCIDR().prefixLength) + assertEquals(0, "::/0".toCIDR().prefixLength) + assertEquals(128, "fd00::1/128".toCIDR().prefixLength) + } + + @Test + fun `rejects an address without a prefix`() { + val error = assertThrows(IllegalArgumentException::class.java) { + "192.168.1.0".toCIDR() + } + assertTrue(error.message!!.contains("Invalid CIDR format")) + } + + @Test + fun `rejects an address with too many segments`() { + assertThrows(IllegalArgumentException::class.java) { + "192.168.1.0/24/8".toCIDR() + } + } + + @Test + fun `rejects a non-numeric prefix`() { + val error = assertThrows(IllegalArgumentException::class.java) { + "192.168.1.0/abc".toCIDR() + } + assertTrue(error.message!!.contains("Invalid prefix length")) + } + + @Test + fun `rejects an IPv4 prefix above 32`() { + assertThrows(IllegalArgumentException::class.java) { + "192.168.1.0/33".toCIDR() + } + } + + @Test + fun `rejects an IPv6 prefix above 128`() { + assertThrows(IllegalArgumentException::class.java) { + "fd00::/129".toCIDR() + } + } + + @Test + fun `rejects a negative prefix`() { + assertThrows(IllegalArgumentException::class.java) { + "192.168.1.0/-1".toCIDR() + } + } +} + +class RouteAddressTest { + @Test + fun `splits a mixed route list by address family`() { + val options = optionsWithRoutes( + listOf("192.168.1.0/24", "fd00::/8", "10.0.0.0/8", "2000::/3"), + ) + + val ipv4 = options.getIpv4RouteAddress() + val ipv6 = options.getIpv6RouteAddress() + + assertEquals(2, ipv4.size) + assertEquals(2, ipv6.size) + assertTrue(ipv4.all { it.address is Inet4Address }) + assertTrue(ipv6.all { it.address is Inet6Address }) + } + + @Test + fun `returns empty lists for an empty route list`() { + val options = optionsWithRoutes(emptyList()) + + assertTrue(options.getIpv4RouteAddress().isEmpty()) + assertTrue(options.getIpv6RouteAddress().isEmpty()) + } + + @Test + fun `preserves the configured order within a family`() { + val options = optionsWithRoutes( + listOf("10.0.0.0/8", "fd00::/8", "192.168.0.0/16"), + ) + + assertEquals( + listOf(8, 16), + options.getIpv4RouteAddress().map { it.prefixLength }, + ) + } + + @Test + fun `propagates a malformed entry instead of silently dropping it`() { + val options = optionsWithRoutes(listOf("192.168.1.0/24", "not-a-cidr")) + + assertThrows(IllegalArgumentException::class.java) { + options.getIpv4RouteAddress() + } + } +} diff --git a/arb/intl_en.arb b/arb/intl_en.arb index e80f21e0e5..e197fa9f6b 100644 --- a/arb/intl_en.arb +++ b/arb/intl_en.arb @@ -1,5 +1,6 @@ { "rule": "Rule", + "rules": "Rules", "global": "Global", "direct": "Direct", "dashboard": "Dashboard", @@ -17,6 +18,9 @@ "networkDetection": "Network detection", "upload": "Upload", "download": "Download", + "usedTraffic": "Used traffic", + "totalTraffic": "Total traffic", + "expireTime": "Expiration time", "nullProfileDesc": "No profile, Please add a profile", "settings": "Settings", "language": "Language", @@ -90,8 +94,8 @@ "appAccessControl": "App access control", "accessControlAllowDesc": "Only allow selected app to enter VPN", "accessControlNotAllowDesc": "The selected application will be excluded from VPN", + "accessControlDisabledDesc": "App access control is turned off", "selected": "Selected", - "proxyPort": "ProxyPort", "port": "Port", "logLevel": "LogLevel", "show": "Show", @@ -105,6 +109,8 @@ "stopVpn": "Stopping VPN...", "compatible": "Compatibility mode", "notSelectedTip": "The current proxy group cannot be selected.", + "changeProxyFailedTip": "Failed to switch proxy, the previous selection has been restored", + "databaseWriteFailedTip": "Failed to save the change, it has been rolled back", "tip": "tip", "account": "Account", "backup": "Backup", @@ -206,7 +212,7 @@ "loopbackDesc": "Used for UWP loopback unlocking", "providers": "Providers", "proxyProviders": "Proxy providers", - "ruleProviders": "Rule providers", + "subscriptionInfo": "Subscription info", "overrideDns": "Override Dns", "overrideDnsDesc": "Turning it on will override the DNS options in the profile", "status": "Status", @@ -319,7 +325,9 @@ "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.", + "crashDetectedTip": "The app failed to finish launching twice in a row. To break the loop, the profile {name} has been deselected and automatic setup was skipped. You can select it again at any time.", + "launchInterrupted": "Launch did not finish", + "launchInterruptedTip": "The app exited unexpectedly while it was starting up last time. Automatic setup was skipped for this launch; you can start it manually to retry.", "clearData": "Clear Data", "textScale": "Text Scaling", "internet": "Internet", @@ -339,7 +347,6 @@ "nullTip": "No {label} yet", "script": "Script", "color": "Color", - "rename": "Rename", "unnamed": "Unnamed", "pleaseEnterScriptName": "Please enter a script name", "mixedPort": "Mixed Port", @@ -393,7 +400,6 @@ "confirmForceCrashCore": "Are you sure you want to force crash the core?", "confirmClearAllData": "Are you sure you want to clear all data?", "loading": "Loading...", - "loadTest": "Load test", "yearsAgo": "{count, plural, =1{1 year ago} other{{count} years ago}}", "monthsAgo": "{count, plural, =1{1 month ago} other{{count} months ago}}", "daysAgo": "{count, plural, =1{1 day ago} other{{count} days ago}}", @@ -528,7 +534,7 @@ "prerequisites": "Prerequisites", "ignoreBatteryOptimization": "Ignore Battery Optimization", "batteryOptimizationDesc": "To ensure background operation, please disable battery optimization for this app. Tap to go to settings.", - "batteryOptimizationStatusTip": "Affected by the system, this status may not always be accurate.", + "batteryOptimizationStatusTip": "Due to system limitations, battery optimization status cannot be accurately retrieved while running.", "locationPermission": "Location Permission", "locationPermissionDesc": "According to system requirements, obtaining the Wi-Fi name requires you to grant location permission.", "excludeSsids": "Exclude SSIDs", @@ -549,9 +555,96 @@ "hours": "hours", "hoursCount": "{count} hours", "geoResources": "Geo Resources", - "geoUpdating": "Updating {name}...", "geoSkipped": "{name} is already up to date", "geoUpdated": "{name} updated", "secondsCount": "{count} seconds", - "entriesCount": "{count} entries" -} + "entriesCount": "{count} entries", + "proxiesCount": "{count, plural, =1{1 proxy} other{{count} proxies}}", + "rulesCount": "{count, plural, =1{1 rule} other{{count} rules}}", + "changelogBreaking": "Breaking changes", + "changelogFeatures": "New features", + "changelogFixes": "Bug fixes", + "changelogPerformance": "Performance", + "changelogReverts": "Reverts", + "close": "Close", + "back": "Back", + "minimize": "Minimize", + "maximize": "Maximize", + "unmaximize": "Restore", + "pinWindow": "Pin window", + "unpinWindow": "Unpin window", + "toggleLabel": "Toggle labels", + "torch": "Torch", + "pickFromAlbum": "Pick from album", + "blockConnection": "Block connection", + "closeConnections": "Close connections", + "scrollToSelected": "Scroll to selected", + "showMore": "Show more", + "showLess": "Show less", + "previousMatch": "Previous match", + "nextMatch": "Next match", + "clearSearch": "Clear search", + "addWidget": "Add widget", + "showPassword": "Show password", + "hidePassword": "Hide password", +"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." +} \ No newline at end of file diff --git a/arb/intl_ja.arb b/arb/intl_ja.arb index 13d41bf4e6..fd9a24a2af 100644 --- a/arb/intl_ja.arb +++ b/arb/intl_ja.arb @@ -1,5 +1,6 @@ { "rule": "ルール", + "rules": "ルール", "global": "グローバル", "direct": "ダイレクト", "dashboard": "ダッシュボード", @@ -17,6 +18,9 @@ "networkDetection": "ネットワーク検出", "upload": "アップロード", "download": "ダウンロード", + "usedTraffic": "使用済み通信量", + "totalTraffic": "総通信量", + "expireTime": "有効期限", "nullProfileDesc": "プロファイルがありません。追加してください", "settings": "設定", "language": "言語", @@ -90,8 +94,8 @@ "appAccessControl": "アプリアクセス制御", "accessControlAllowDesc": "選択したアプリのみVPNを許可", "accessControlNotAllowDesc": "選択したアプリをVPNから除外", + "accessControlDisabledDesc": "アプリアクセス制御はオフです", "selected": "選択済み", - "proxyPort": "プロキシポート", "port": "ポート", "logLevel": "ログレベル", "show": "表示", @@ -105,6 +109,8 @@ "stopVpn": "VPNを停止中...", "compatible": "互換モード", "notSelectedTip": "現在のプロキシグループは選択できません", + "changeProxyFailedTip": "プロキシの切り替えに失敗しました。前回の選択に戻しました", + "databaseWriteFailedTip": "変更の保存に失敗したため、元に戻しました", "tip": "ヒント", "account": "アカウント", "backup": "バックアップ", @@ -206,7 +212,7 @@ "loopbackDesc": "UWPループバック解除用", "providers": "プロバイダー", "proxyProviders": "プロキシプロバイダー", - "ruleProviders": "ルールプロバイダー", + "subscriptionInfo": "サブスクリプション情報", "overrideDns": "DNS上書き", "overrideDnsDesc": "有効化するとプロファイルのDNS設定を上書き", "status": "ステータス", @@ -319,7 +325,9 @@ "messageTestTip": "これはメッセージです。", "crashTest": "クラッシュテスト", "crashDetected": "クラッシュを検出しました", - "crashDetectedTip": "前回の実行中にアプリがクラッシュしました。クラッシュの繰り返しを防ぐため、現在のプロファイルを解除し、設定の自動セットアップをスキップしました。", + "crashDetectedTip": "アプリの起動が 2 回連続で完了しませんでした。繰り返しを断ち切るため、プロファイル {name} の選択を解除し、設定の自動セットアップをスキップしました。いつでも選択し直せます。", + "launchInterrupted": "起動が完了しませんでした", + "launchInterruptedTip": "前回、アプリは起動中に予期せず終了しました。今回は設定の自動セットアップをスキップしました。手動で起動して再試行できます。", "clearData": "データを消去", "textScale": "テキストスケーリング", "internet": "インターネット", @@ -339,7 +347,6 @@ "nullTip": "まだ{label}はありません", "script": "スクリプト", "color": "カラー", - "rename": "リネーム", "unnamed": "無題", "pleaseEnterScriptName": "スクリプト名を入力してください", "mixedPort": "混合ポート", @@ -393,7 +400,6 @@ "confirmForceCrashCore": "コアを強制的にクラッシュさせてもよろしいですか?", "confirmClearAllData": "すべてのデータをクリアしてもよろしいですか?", "loading": "読み込み中...", - "loadTest": "読み込みテスト", "yearsAgo": "{count}年前", "monthsAgo": "{count}ヶ月前", "daysAgo": "{count}日前", @@ -479,7 +485,7 @@ "ruleActionDomainSuffixDesc": "ドメイン接尾辞をマッチング", "ruleActionDomainKeywordDesc": "ドメインキーワードをマッチング", "ruleActionDomainRegexDesc": "ワイルドカードマッチング(*と?のみサポート)", - "ruleActionGeositeDesc": "Match domains within Geosite", + "ruleActionGeositeDesc": "Geosite 内のドメインに一致", "ruleActionIpCidrDesc": "IPアドレス範囲をマッチング", "ruleActionIpCidr6Desc": "IPアドレス範囲をマッチング(IP-CIDR6はエイリアスです)", "ruleActionIpSuffixDesc": "IP接尾辞範囲をマッチング", @@ -522,20 +528,20 @@ "proxyGroupDetectedAbnormal": "現在のプロキシグループが異常であることを検出しました", "proxyProviderDetectedAbnormal": "選択されたプロキシプロバイダーに異常があることを検出しました", "proxyDetectedAbnormal": "選択されたプロキシに異常があることを検出しました", - "createProfile": "Create Profile", - "locationPermissionRequired": "Location Permission Required", - "locationPermissionGuide": "1. Open System Settings > Privacy & Security\n2. Choose Location Services\n3. Find and check {appName} in the right list\n\nAfter completing the setup, return to the app and use it normally. Thank you for your cooperation.", - "prerequisites": "Prerequisites", - "ignoreBatteryOptimization": "Ignore Battery Optimization", - "batteryOptimizationDesc": "To ensure background operation, please disable battery optimization for this app. Tap to go to settings.", - "batteryOptimizationStatusTip": "システムの影響により、この状態は必ずしも正確とは限りません。", - "locationPermission": "Location Permission", - "locationPermissionDesc": "According to system requirements, obtaining the Wi-Fi name requires you to grant location permission.", - "excludeSsids": "Exclude SSIDs", - "excludeSsidsDesc": "When connected to an excluded SSID Wi-Fi, the app running state will be automatically switched.", - "ssidsEmpty": "SSIDs is empty", - "onDemand": "On Demand", - "onDemandDesc": "Configure the program running state for specific scenarios", + "createProfile": "プロファイルを作成", + "locationPermissionRequired": "位置情報の権限が必要です", + "locationPermissionGuide": "1. システム設定 > プライバシーとセキュリティ を開く\n2. 位置情報サービス を選択\n3. 右側のリストで {appName} を見つけてチェックする\n\n設定が完了したらアプリに戻ると通常どおり使用できます。ご協力ありがとうございます。", + "prerequisites": "前提条件", + "ignoreBatteryOptimization": "バッテリー最適化を無視", + "batteryOptimizationDesc": "バックグラウンド動作を保証するため、このアプリのバッテリー最適化を無効にしてください。タップして設定に移動します。", + "batteryOptimizationStatusTip": "システム制限のため、実行中はバッテリー最適化の状態を正確に取得できません。", + "locationPermission": "位置情報の権限", + "locationPermissionDesc": "システムの要件により、Wi-Fi 名を取得するには位置情報の権限を許可する必要があります。", + "excludeSsids": "SSID を除外", + "excludeSsidsDesc": "除外した SSID の Wi-Fi に接続すると、アプリの動作状態が自動的に切り替わります。", + "ssidsEmpty": "SSID が空です", + "onDemand": "オンデマンド", + "onDemandDesc": "特定のシーンでのアプリの動作状態を設定します", "locationPermissionDeniedMessage": "位置情報の権限が拒否されたため、現在の Wi-Fi 名を取得できません。システム設定で位置情報の権限を手動で有効にしてください。", "addSsid": "SSIDを追加", "editSsid": "SSIDを編集", @@ -549,9 +555,96 @@ "hours": "時間", "hoursCount": "{count} 時間", "geoResources": "Geoリソース", - "geoUpdating": "{name}を更新中...", "geoSkipped": "{name} はすでに最新です", "geoUpdated": "{name} 更新済み", "secondsCount": "{count} 秒", - "entriesCount": "{count} エントリ" -} + "entriesCount": "{count} エントリ", + "proxiesCount": "{count} プロキシ", + "rulesCount": "{count} ルール", + "changelogBreaking": "重大な変更", + "changelogFeatures": "新機能", + "changelogFixes": "不具合修正", + "changelogPerformance": "パフォーマンス", + "changelogReverts": "取り消し", + "close": "閉じる", + "back": "戻る", + "minimize": "最小化", + "maximize": "最大化", + "unmaximize": "元のサイズに戻す", + "pinWindow": "ウィンドウを最前面に固定", + "unpinWindow": "最前面固定を解除", + "toggleLabel": "ラベルを切り替え", + "torch": "ライト", + "pickFromAlbum": "アルバムから選択", + "blockConnection": "接続をブロック", + "closeConnections": "接続を閉じる", + "scrollToSelected": "選択項目へスクロール", + "showMore": "展開する", + "showLess": "折りたたむ", + "previousMatch": "前の一致", + "nextMatch": "次の一致", + "clearSearch": "検索をクリア", + "addWidget": "ウィジェットを追加", + "showPassword": "パスワードを表示", + "hidePassword": "パスワードを非表示", +"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 の起動を確認してください。" +} \ No newline at end of file diff --git a/arb/intl_ru.arb b/arb/intl_ru.arb index bcd965a6bb..f2275870c0 100644 --- a/arb/intl_ru.arb +++ b/arb/intl_ru.arb @@ -1,5 +1,6 @@ { "rule": "Правило", + "rules": "Правила", "global": "Глобальный", "direct": "Прямой", "dashboard": "Панель управления", @@ -17,6 +18,9 @@ "networkDetection": "Обнаружение сети", "upload": "Загрузка", "download": "Скачивание", + "usedTraffic": "Использованный трафик", + "totalTraffic": "Общий трафик", + "expireTime": "Срок действия", "nullProfileDesc": "Нет профиля, пожалуйста, добавьте профиль", "settings": "Настройки", "language": "Язык", @@ -43,7 +47,7 @@ "silentLaunchDesc": "Запуск в фоновом режиме", "autoRun": "Автозапуск", "autoRunDesc": "Автоматический запуск при открытии приложения", - "logcat": "Logcat", + "logcat": "Журнал системы", "logcatDesc": "Отключение скроет запись логов", "autoCheckUpdate": "Автопроверка обновлений", "autoCheckUpdateDesc": "Автоматически проверять обновления при запуске приложения", @@ -90,8 +94,8 @@ "appAccessControl": "Контроль доступа приложений", "accessControlAllowDesc": "Разрешить только выбранным приложениям доступ к VPN", "accessControlNotAllowDesc": "Выбранные приложения будут исключены из VPN", + "accessControlDisabledDesc": "Контроль доступа приложений отключен", "selected": "Выбрано", - "proxyPort": "Порт прокси", "port": "Порт", "logLevel": "Уровень логов", "show": "Показать", @@ -105,6 +109,8 @@ "stopVpn": "Остановка VPN...", "compatible": "Режим совместимости", "notSelectedTip": "Текущая группа прокси не может быть выбрана.", + "changeProxyFailedTip": "Не удалось переключить прокси, предыдущий выбор восстановлен", + "databaseWriteFailedTip": "Не удалось сохранить изменение, оно было отменено", "tip": "подсказка", "account": "Аккаунт", "backup": "Резервное копирование", @@ -206,7 +212,7 @@ "loopbackDesc": "Используется для разблокировки Loopback UWP", "providers": "Провайдеры", "proxyProviders": "Провайдеры прокси", - "ruleProviders": "Провайдеры правил", + "subscriptionInfo": "Информация о подписке", "overrideDns": "Переопределить DNS", "overrideDnsDesc": "Включение переопределит настройки DNS в профиле", "status": "Статус", @@ -319,7 +325,9 @@ "messageTestTip": "Это сообщение.", "crashTest": "Тест на сбои", "crashDetected": "Обнаружен сбой", - "crashDetectedTip": "Во время предыдущего запуска произошёл сбой приложения. Чтобы предотвратить повторный сбой, текущий профиль был сброшен, а автоматическая настройка конфигурации пропущена.", + "crashDetectedTip": "Приложение два раза подряд не смогло завершить запуск. Чтобы разорвать цикл, профиль {name} снят с выбора, а автоматическая настройка пропущена. Вы можете выбрать его снова в любой момент.", + "launchInterrupted": "Запуск не завершён", + "launchInterruptedTip": "В прошлый раз приложение неожиданно завершилось во время запуска. Автоматическая настройка для этого запуска пропущена; вы можете запустить её вручную.", "clearData": "Очистить данные", "textScale": "Масштабирование текста", "internet": "Интернет", @@ -339,7 +347,6 @@ "nullTip": "{label} пока отсутствуют", "script": "Скрипт", "color": "Цвет", - "rename": "Переименовать", "unnamed": "Без имени", "pleaseEnterScriptName": "Пожалуйста, введите название скрипта", "mixedPort": "Смешанный порт", @@ -393,7 +400,6 @@ "confirmForceCrashCore": "Вы уверены, что хотите принудительно аварийно завершить работу ядра?", "confirmClearAllData": "Вы уверены, что хотите очистить все данные?", "loading": "Загрузка...", - "loadTest": "Тест загрузки", "yearsAgo": "{count, plural, one{{count} год назад} few{{count} года назад} many{{count} лет назад} other{{count} года назад}}", "monthsAgo": "{count, plural, one{{count} месяц назад} few{{count} месяца назад} many{{count} месяцев назад} other{{count} месяца назад}}", "daysAgo": "{count, plural, one{{count} день назад} few{{count} дня назад} many{{count} дней назад} other{{count} дня назад}}", @@ -522,25 +528,25 @@ "proxyGroupDetectedAbnormal": "Обнаружена аномалия текущей группы прокси", "proxyProviderDetectedAbnormal": "Обнаружена аномалия выбранных провайдеров прокси", "proxyDetectedAbnormal": "Обнаружена аномалия выбранных прокси", - "createProfile": "Create Profile", - "locationPermissionRequired": "Location Permission Required", - "locationPermissionGuide": "1. Open System Settings > Privacy & Security\n2. Choose Location Services\n3. Find and check {appName} in the right list\n\nAfter completing the setup, return to the app and use it normally. Thank you for your cooperation.", - "prerequisites": "Prerequisites", - "ignoreBatteryOptimization": "Ignore Battery Optimization", - "batteryOptimizationDesc": "To ensure background operation, please disable battery optimization for this app. Tap to go to settings.", - "batteryOptimizationStatusTip": "Из-за особенностей системы этот статус не всегда может быть точным.", - "locationPermission": "Location Permission", - "locationPermissionDesc": "According to system requirements, obtaining the Wi-Fi name requires you to grant location permission.", - "excludeSsids": "Exclude SSIDs", - "excludeSsidsDesc": "When connected to an excluded SSID Wi-Fi, the app running state will be automatically switched.", - "ssidsEmpty": "SSIDs is empty", - "onDemand": "On Demand", - "onDemandDesc": "Configure the program running state for specific scenarios", + "createProfile": "Создать профиль", + "locationPermissionRequired": "Требуется разрешение на геолокацию", + "locationPermissionGuide": "1. Откройте Системные настройки > Конфиденциальность и безопасность\n2. Выберите Службы геолокации\n3. Найдите и отметьте {appName} в списке справа\n\nПосле настройки вернитесь в приложение и продолжайте работу. Спасибо за сотрудничество.", + "prerequisites": "Предварительные условия", + "ignoreBatteryOptimization": "Игнорировать оптимизацию батареи", + "batteryOptimizationDesc": "Чтобы приложение работало в фоне, отключите для него оптимизацию батареи. Нажмите, чтобы перейти к настройкам.", + "batteryOptimizationStatusTip": "Из-за системных ограничений статус оптимизации батареи не может быть точно получен во время работы.", + "locationPermission": "Разрешение на геолокацию", + "locationPermissionDesc": "По требованию системы для получения имени сети Wi-Fi необходимо разрешение на геолокацию.", + "excludeSsids": "Исключить SSID", + "excludeSsidsDesc": "При подключении к Wi-Fi с исключённым SSID состояние работы приложения переключается автоматически.", + "ssidsEmpty": "Список SSID пуст", + "onDemand": "По требованию", + "onDemandDesc": "Настройте состояние работы приложения для определённых сценариев", "locationPermissionDeniedMessage": "Разрешение на геолокацию отклонено, поэтому невозможно получить имя текущей Wi-Fi сети. Включите разрешение на геолокацию вручную в системных настройках.", "addSsid": "Добавить SSID", "editSsid": "Изменить SSID", "authorized": "Разрешено", - "tapToAuthorize": "Нажмите, чтобы разрешить", + "tapToAuthorize": "Разрешить", "suspended": "Приостановлено...", "geoOptions": "Настройки Geo", "geoAutoUpdate": "Автообновление", @@ -549,9 +555,96 @@ "hours": "часов", "hoursCount": "{count} часов", "geoResources": "Ресурсы Geo", - "geoUpdating": "Обновление {name}...", "geoSkipped": "Для {name} уже установлена последняя версия", "geoUpdated": "{name} обновлено", "secondsCount": "{count} секунд", - "entriesCount": "{count} записей" -} + "entriesCount": "{count} записей", + "proxiesCount": "{count} прокси", + "rulesCount": "{count, plural, one{{count} правило} few{{count} правила} many{{count} правил} other{{count} правила}}", + "changelogBreaking": "Важные изменения", + "changelogFeatures": "Новые функции", + "changelogFixes": "Исправления", + "changelogPerformance": "Производительность", + "changelogReverts": "Откаты", + "close": "Закрыть", + "back": "Назад", + "minimize": "Свернуть", + "maximize": "Развернуть", + "unmaximize": "Восстановить", + "pinWindow": "Закрепить окно", + "unpinWindow": "Открепить окно", + "toggleLabel": "Переключить подписи", + "torch": "Фонарик", + "pickFromAlbum": "Выбрать из галереи", + "blockConnection": "Заблокировать соединение", + "closeConnections": "Закрыть соединения", + "scrollToSelected": "Прокрутить к выбранному", + "showMore": "Показать больше", + "showLess": "Показать меньше", + "previousMatch": "Предыдущее совпадение", + "nextMatch": "Следующее совпадение", + "clearSearch": "Очистить поиск", + "addWidget": "Добавить виджет", + "showPassword": "Показать пароль", + "hidePassword": "Скрыть пароль", +"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 запущен." +} \ No newline at end of file diff --git a/arb/intl_zh_CN.arb b/arb/intl_zh_CN.arb index e4e805450a..0eb0fb75bf 100644 --- a/arb/intl_zh_CN.arb +++ b/arb/intl_zh_CN.arb @@ -1,5 +1,6 @@ { "rule": "规则", + "rules": "规则", "global": "全局", "direct": "直连", "dashboard": "仪表盘", @@ -17,6 +18,9 @@ "networkDetection": "网络检测", "upload": "上传", "download": "下载", + "usedTraffic": "已用流量", + "totalTraffic": "总流量", + "expireTime": "到期时间", "nullProfileDesc": "没有配置文件,请先添加配置文件", "settings": "设置", "language": "语言", @@ -90,8 +94,8 @@ "appAccessControl": "应用访问控制", "accessControlAllowDesc": "只允许选中应用进入VPN", "accessControlNotAllowDesc": "选中应用将会被排除在VPN之外", + "accessControlDisabledDesc": "应用访问控制已关闭", "selected": "已选择", - "proxyPort": "代理端口", "port": "端口", "logLevel": "日志等级", "show": "显示", @@ -105,6 +109,8 @@ "stopVpn": "正在停止VPN...", "compatible": "兼容模式", "notSelectedTip": "当前代理组无法选中", + "changeProxyFailedTip": "切换代理失败,已恢复上一次的选择", + "databaseWriteFailedTip": "保存更改失败,已回滚", "tip": "提示", "account": "账号", "backup": "备份", @@ -204,9 +210,9 @@ "options": "选项", "loopback": "回环解锁工具", "loopbackDesc": "用于UWP回环解锁", - "providers": "提供者", + "providers": "外部资源", "proxyProviders": "代理集", - "ruleProviders": "规则集", + "subscriptionInfo": "订阅信息", "overrideDns": "覆写DNS", "overrideDnsDesc": "开启后将覆盖配置中的DNS选项", "status": "状态", @@ -319,7 +325,9 @@ "messageTestTip": "这是一条消息。", "crashTest": "崩溃测试", "crashDetected": "检测到崩溃", - "crashDetectedTip": "检测到应用上次运行发生崩溃。为避免重复崩溃,已清除当前配置选择,并跳过本次自动配置。", + "crashDetectedTip": "应用连续两次未能完成启动。为打断崩溃循环,已取消选中配置 {name},并跳过本次自动配置,你可以随时重新选中它。", + "launchInterrupted": "启动未完成", + "launchInterruptedTip": "应用上次在启动过程中意外退出。已跳过本次自动配置,你可以手动启动重试。", "clearData": "清除数据", "textScale": "文本缩放", "internet": "互联网", @@ -339,7 +347,6 @@ "nullTip": "暂无{label}", "script": "脚本", "color": "颜色", - "rename": "重命名", "unnamed": "未命名", "pleaseEnterScriptName": "请输入脚本名称", "mixedPort": "混合端口", @@ -393,7 +400,6 @@ "confirmForceCrashCore": "确定要强制崩溃核心?", "confirmClearAllData": "确定要清除所有数据?", "loading": "加载中...", - "loadTest": "加载测试", "yearsAgo": "{count} 年前", "monthsAgo": "{count} 个月前", "daysAgo": "{count} 天前", @@ -528,7 +534,7 @@ "prerequisites": "前置条件", "ignoreBatteryOptimization": "忽略电池优化", "batteryOptimizationDesc": "为保证后台运行,请关闭本应用的电池优化。点击前往设置。", - "batteryOptimizationStatusTip": "受系统影响,不代表一定准确", + "batteryOptimizationStatusTip": "由于系统限制,运行状态下无法正确获取电池优化状态", "locationPermission": "位置权限", "locationPermissionDesc": "根据系统要求,获取Wi-Fi名称需要您授予位置权限。", "excludeSsids": "排除SSIDs", @@ -549,9 +555,96 @@ "hours": "小时", "hoursCount": "{count} 小时", "geoResources": "Geo 资源", - "geoUpdating": "正在更新 {name}...", "geoSkipped": "{name} 已是最新版本", "geoUpdated": "{name} 已更新", "secondsCount": "{count} 秒", - "entriesCount": "{count} 个条目" -} + "entriesCount": "{count} 个条目", + "proxiesCount": "{count} 个代理", + "rulesCount": "{count} 条规则", + "changelogBreaking": "重大变更", + "changelogFeatures": "新功能", + "changelogFixes": "问题修复", + "changelogPerformance": "性能优化", + "changelogReverts": "已回滚", + "close": "关闭", + "back": "返回", + "minimize": "最小化", + "maximize": "最大化", + "unmaximize": "向下还原", + "pinWindow": "窗口置顶", + "unpinWindow": "取消置顶", + "toggleLabel": "切换标签", + "torch": "手电筒", + "pickFromAlbum": "从相册选择", + "blockConnection": "阻止连接", + "closeConnections": "关闭连接", + "scrollToSelected": "滚动到已选", + "showMore": "展开", + "showLess": "收起", + "previousMatch": "上一个匹配", + "nextMatch": "下一个匹配", + "clearSearch": "清除搜索", + "addWidget": "添加组件", + "showPassword": "显示密码", + "hidePassword": "隐藏密码", +"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 是否已启动。" +} \ No newline at end of file diff --git a/changelog.json b/changelog.json new file mode 100644 index 0000000000..bd6a31e550 --- /dev/null +++ b/changelog.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 2, + "versions": [ + { + "version": "0.8.96", + "tag": "v0.8.96", + "date": "2026-08-17", + "prerelease": false, + "groups": [ + { + "type": "fix", + "entries": [ + { + "id": "7fb4f4f", + "text": "Fix whole group delay test failing on Windows" + } + ] + }, + { + "type": "perf", + "entries": [ + { + "id": "903e2b8", + "text": "Optimize package icon loading and connections polling" + } + ] + } + ] + } + ] +} diff --git a/core/Clash.Meta b/core/Clash.Meta index 0f7f05adff..70f0570405 160000 --- a/core/Clash.Meta +++ b/core/Clash.Meta @@ -1 +1 @@ -Subproject commit 0f7f05adff5e2c49775a112dcfe05a6aa36fda0c +Subproject commit 70f0570405c3c2c47bb113b88db95006d239b346 diff --git a/core/bride.c b/core/bride.c index b432d29a95..6563909b43 100644 --- a/core/bride.c +++ b/core/bride.c @@ -1,3 +1,5 @@ +//go:build android && cgo + #include "bride.h" void (*release_object_func)(void *obj); @@ -11,21 +13,36 @@ char* (*resolve_process_func)(void *tun_interface,int protocol, const char *sour void (*result_func)(void *invoke_Interface, const char *data); void protect(void *tun_interface, int fd) { + if (protect_func == NULL) { + return; + } protect_func(tun_interface, fd); } char* resolve_process(void *tun_interface, int protocol, const char *source, const char *target, int uid) { + if (resolve_process_func == NULL) { + return NULL; + } return resolve_process_func(tun_interface, protocol, source, target, uid); } void release_object(void *obj) { + if (release_object_func == NULL) { + return; + } release_object_func(obj); } void free_string(char *data) { + if (free_string_func == NULL) { + return; + } free_string_func(data); } void result(void *invoke_Interface, const char *data) { - return result_func(invoke_Interface, data); -} \ No newline at end of file + if (result_func == NULL) { + return; + } + result_func(invoke_Interface, data); +} diff --git a/core/common.go b/core/common.go index 13717488d2..74b0320be0 100644 --- a/core/common.go +++ b/core/common.go @@ -10,12 +10,13 @@ import ( "path/filepath" "runtime" "sync" + "sync/atomic" + "time" "github.com/metacubex/mihomo/adapter" "github.com/metacubex/mihomo/adapter/inbound" "github.com/metacubex/mihomo/adapter/outboundgroup" "github.com/metacubex/mihomo/adapter/provider" - "github.com/metacubex/mihomo/common/batch" "github.com/metacubex/mihomo/component/dialer" "github.com/metacubex/mihomo/component/resolver" "github.com/metacubex/mihomo/component/updater" @@ -27,28 +28,53 @@ import ( "github.com/metacubex/mihomo/hub/executor" "github.com/metacubex/mihomo/hub/route" "github.com/metacubex/mihomo/listener" + LC "github.com/metacubex/mihomo/listener/config" "github.com/metacubex/mihomo/log" rp "github.com/metacubex/mihomo/rules/provider" "github.com/metacubex/mihomo/tunnel" ) +const ( + delayTestConcurrency = 50 + defaultTestURL = "https://www.gstatic.com/generate_204" + defaultDelayTestTimeout = 5 * time.Second +) + var ( + configMu sync.Mutex currentConfig *config.Config - version = 0 - isRunning = false - runLock sync.Mutex - mBatch, _ = batch.New[bool](context.Background(), batch.WithConcurrencyNum[bool](50)) - debugError = false + + // selectMu serialises proxy-group selection writes. mihomo's Selector.Set + // writes s.selected with no lock of its own, so something has to; configMu + // used to, which made a tap on a node wait out a whole config apply, + // provider downloads included, for a write that touches one field. + // patchSelectGroup takes it under configMu, fixing the order as + // configMu -> selectMu. + selectMu sync.Mutex + + isInit atomic.Bool + isRunning atomic.Bool + sdkVersion atomic.Int32 + testURL atomic.Pointer[string] + + delayTestSlots = make(chan struct{}, delayTestConcurrency) + + debugStderr = os.Getenv("FLCLASH_CORE_DEBUG") != "" ) -func getExternalProvidersRaw() map[string]cp.Provider { +var ( + errConfigNotApplied = errors.New("config is not applied") + errNotExternalProvider = errors.New("not external provider") +) + +func externalProviders() map[string]cp.Provider { eps := make(map[string]cp.Provider) - for n, p := range tunnel.Providers() { + for n, p := range tunnel.ProvidersSnapshot() { if p.VehicleType() != cp.Compatible { eps[n] = p } } - for n, p := range tunnel.RuleProviders() { + for n, p := range tunnel.RuleProvidersSnapshot() { if p.VehicleType() != cp.Compatible { eps[n] = p } @@ -56,74 +82,68 @@ func getExternalProvidersRaw() map[string]cp.Provider { return eps } +func lookupExternalProvider(name string) (cp.Provider, bool) { + if p, exist := tunnel.RuleProvidersSnapshot()[name]; exist && p.VehicleType() != cp.Compatible { + return p, true + } + if p, exist := tunnel.ProvidersSnapshot()[name]; exist && p.VehicleType() != cp.Compatible { + return p, true + } + return nil, false +} + func toExternalProvider(p cp.Provider) (*ExternalProvider, error) { - switch p.(type) { + switch typed := p.(type) { case *provider.ProxySetProvider: - psp := p.(*provider.ProxySetProvider) return &ExternalProvider{ - Name: psp.Name(), - Type: psp.Type().String(), - VehicleType: psp.VehicleType().String(), - Count: psp.Count(), - UpdateAt: psp.UpdatedAt(), - Path: psp.Vehicle().Path(), - SubscriptionInfo: psp.GetSubscriptionInfo(), + Name: typed.Name(), + Type: typed.Type().String(), + VehicleType: typed.VehicleType().String(), + Count: typed.Count(), + UpdateAt: typed.UpdatedAt(), + Path: typed.Vehicle().Path(), + SubscriptionInfo: typed.GetSubscriptionInfo(), }, nil case *rp.RuleSetProvider: - rsp := p.(*rp.RuleSetProvider) return &ExternalProvider{ - Name: rsp.Name(), - Type: rsp.Type().String(), - VehicleType: rsp.VehicleType().String(), - Count: rsp.Count(), - UpdateAt: rsp.UpdatedAt(), - Path: rsp.Vehicle().Path(), + Name: typed.Name(), + Type: typed.Type().String(), + VehicleType: typed.VehicleType().String(), + Count: typed.Count(), + UpdateAt: typed.UpdatedAt(), + Path: typed.Vehicle().Path(), }, nil default: - return nil, errors.New("not external provider") + return nil, errNotExternalProvider } } -func sideUpdateExternalProvider(p cp.Provider, bytes []byte) error { - switch p.(type) { +func sideUpdateExternalProvider(p cp.Provider, data []byte) error { + switch typed := p.(type) { case *provider.ProxySetProvider: - psp := p.(*provider.ProxySetProvider) - _, _, err := psp.SideUpdate(bytes) - if err == nil { - return err - } - return nil - case rp.RuleSetProvider: - rsp := p.(*rp.RuleSetProvider) - _, _, err := rsp.SideUpdate(bytes) - if err == nil { - return err - } - return nil + _, _, err := typed.SideUpdate(data) + return err + case *rp.RuleSetProvider: + _, _, err := typed.SideUpdate(data) + return err default: - return errors.New("not external provider") + return errNotExternalProvider } } -func updateListeners() { - if !isRunning { +func updateListeners(cfg *config.Config) { + if cfg == nil || !isRunning.Load() { return } - if currentConfig == nil { - return - } - listeners := currentConfig.Listeners - general := currentConfig.General - listener.PatchInboundListeners(listeners, tunnel.Tunnel, true) + general := cfg.General + listener.PatchInboundListeners(cfg.Listeners, tunnel.Tunnel, true) - allowLan := general.AllowLan - listener.SetAllowLan(allowLan) + listener.SetAllowLan(general.AllowLan) inbound.SetSkipAuthPrefixes(general.SkipAuthPrefixes) inbound.SetAllowedIPs(general.LanAllowedIPs) inbound.SetDisAllowedIPs(general.LanDisAllowedIPs) - bindAddress := general.BindAddress - listener.SetBindAddress(bindAddress) + listener.SetBindAddress(general.BindAddress) listener.ReCreateHTTP(general.Port, tunnel.Tunnel) listener.ReCreateSocks(general.SocksPort, tunnel.Tunnel) listener.ReCreateRedir(general.RedirPort, tunnel.Tunnel) @@ -137,11 +157,9 @@ func updateListeners() { } } -func stopListeners() { - listener.StopListener() -} - func patchSelectGroup(mapping map[string]string) { + selectMu.Lock() + defer selectMu.Unlock() for name, proxy := range tunnel.AllProxies() { outbound, ok := proxy.(*adapter.Proxy) if !ok { @@ -164,33 +182,97 @@ func patchSelectGroup(mapping map[string]string) { func defaultSetupParams() *SetupParams { return &SetupParams{ - TestURL: "https://www.gstatic.com/generate_204", + TestURL: defaultTestURL, SelectedMap: map[string]string{}, } } -func readFile(path string) ([]byte, error) { - if _, err := os.Stat(path); os.IsNotExist(err) { - return nil, err +func setTestURL(url string) { + if url == "" { + return } - data, err := os.ReadFile(path) - if err != nil { - return nil, err + constant.DefaultTestURL = url + testURL.Store(&url) +} + +func currentTestURL() string { + if url := testURL.Load(); url != nil && *url != "" { + return *url + } + return defaultTestURL +} + +func acquireDelayTestSlot(ctx context.Context) bool { + select { + case delayTestSlots <- struct{}{}: + return true + case <-ctx.Done(): + return false + } +} + +func releaseDelayTestSlot() { + <-delayTestSlots +} + +func routeConfig(cfg *config.Config) *route.Config { + controller := cfg.Controller + routeCfg := &route.Config{ + Addr: controller.ExternalController, + TLSAddr: controller.ExternalControllerTLS, + UnixAddr: controller.ExternalControllerUnix, + PipeAddr: controller.ExternalControllerPipe, + RoutingMark: controller.ExternalControllerRoutingMark, + Secret: controller.Secret, + DohServer: controller.ExternalDohServer, + IsDebug: cfg.General.LogLevel == log.DEBUG, + Cors: route.Cors{ + AllowOrigins: controller.Cors.AllowOrigins, + AllowPrivateNetwork: controller.Cors.AllowPrivateNetwork, + }, + } + if cfg.TLS != nil { + routeCfg.Certificate = cfg.TLS.Certificate + routeCfg.PrivateKey = cfg.TLS.PrivateKey + routeCfg.ClientAuthType = cfg.TLS.ClientAuthType + routeCfg.ClientAuthCert = cfg.TLS.ClientAuthCert + routeCfg.EchKey = cfg.TLS.EchKey } + return routeCfg +} - return data, err +func patchTun(target *LC.Tun, params *tunSchema) { + target.Enable = params.Enable + if params.AutoRoute != nil { + target.AutoRoute = *params.AutoRoute + } + if params.Device != nil { + target.Device = *params.Device + } + if params.RouteAddress != nil { + target.RouteAddress = *params.RouteAddress + } + if params.DNSHijack != nil { + target.DNSHijack = *params.DNSHijack + } + if params.Stack != nil { + target.Stack = *params.Stack + } } -func updateConfig(params *UpdateParams) { - runLock.Lock() - defer runLock.Unlock() +func updateConfig(params *UpdateParams) error { + configMu.Lock() + defer configMu.Unlock() + if currentConfig == nil { + return errConfigNotApplied + } + general := currentConfig.General if params.MixedPort != nil { general.MixedPort = *params.MixedPort } - if params.Sniffing != nil { - general.Sniffing = *params.Sniffing - tunnel.SetSniffing(general.Sniffing) + if params.AllowLan != nil { + general.AllowLan = *params.AllowLan } if params.FindProcessMode != nil { general.FindProcessMode = *params.FindProcessMode @@ -200,10 +282,6 @@ func updateConfig(params *UpdateParams) { general.TCPConcurrent = *params.TCPConcurrent dialer.SetTcpConcurrent(general.TCPConcurrent) } - if params.Interface != nil { - general.Interface = *params.Interface - dialer.DefaultInterface.Store(general.Interface) - } if params.UnifiedDelay != nil { general.UnifiedDelay = *params.UnifiedDelay adapter.UnifiedDelay.Store(general.UnifiedDelay) @@ -220,74 +298,89 @@ func updateConfig(params *UpdateParams) { general.IPv6 = *params.IPv6 resolver.DisableIPv6 = !general.IPv6 } - if params.ExternalController != nil { + if params.Tun != nil { + patchTun(&general.Tun, params.Tun) + } + if params.ExternalController != nil && + *params.ExternalController != currentConfig.Controller.ExternalController { currentConfig.Controller.ExternalController = *params.ExternalController - route.ReCreateServer(&route.Config{ - Addr: currentConfig.Controller.ExternalController, - }) + route.ReCreateServer(routeConfig(currentConfig)) } - if params.Tun != nil { - general.Tun.Enable = params.Tun.Enable - if params.Tun.AutoRoute != nil { - general.Tun.AutoRoute = *params.Tun.AutoRoute - } - if params.Tun.Device != nil { - general.Tun.Device = *params.Tun.Device - } - if params.Tun.RouteAddress != nil { - general.Tun.RouteAddress = *params.Tun.RouteAddress - } - if params.Tun.DNSHijack != nil { - general.Tun.DNSHijack = *params.Tun.DNSHijack - } - if params.Tun.Stack != nil { - general.Tun.Stack = *params.Tun.Stack - } - } + updateListeners(currentConfig) + syncGeoUpdater(params.GeoAutoUpdate, params.GeoUpdateInterval) + return nil +} - if params.GeoAutoUpdate != nil { - updater.SetGeoAutoUpdate(*params.GeoAutoUpdate) +func syncGeoUpdater(autoUpdate *bool, interval *int) { + changed := false + if autoUpdate != nil && *autoUpdate != updater.GeoAutoUpdate() { + updater.SetGeoAutoUpdate(*autoUpdate) + changed = true } - if params.GeoUpdateInterval != nil { - updater.SetGeoUpdateInterval(*params.GeoUpdateInterval) + if interval != nil && *interval != updater.GeoUpdateInterval() { + updater.SetGeoUpdateInterval(*interval) + changed = true } + if !changed { + return + } + reconcileGeoUpdater() +} + +var ( + registerGeoUpdater = updater.RegisterGeoUpdaterWithCancel + stopGeoUpdater = updater.StopGeoUpdater +) - updateListeners() +func reconcileGeoUpdater() { if updater.GeoAutoUpdate() { - updater.RegisterGeoUpdaterWithCancel() + registerGeoUpdater() + return } + stopGeoUpdater() +} + +func loadConfig(path string) (*config.Config, error) { + buf, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return executor.ParseWithBytes(buf) } func applyConfig(params *SetupParams) error { runtime.GC() - runLock.Lock() - defer runLock.Unlock() - var err error - constant.DefaultTestURL = params.TestURL - currentConfig, err = executor.ParseWithPath(filepath.Join(constant.Path.HomeDir(), "config.yaml")) + configMu.Lock() + defer configMu.Unlock() + + setTestURL(params.TestURL) + cfg, err := loadConfig(filepath.Join(constant.Path.HomeDir(), "config.yaml")) if err != nil { - currentConfig, _ = config.ParseRawConfig(config.DefaultRawConfig()) + fallback, fallbackErr := config.ParseRawConfig(config.DefaultRawConfig()) + if fallbackErr != nil { + return err + } + cfg = fallback } - hub.ApplyConfig(currentConfig) + + currentConfig = cfg + hub.ApplyConfig(cfg) patchSelectGroup(params.SelectedMap) - updateListeners() - if updater.GeoAutoUpdate() { - updater.RegisterGeoUpdaterWithCancel() - } + updateListeners(cfg) + reconcileGeoUpdater() return err } func UnmarshalJson(data []byte, v any) error { decoder := json.NewDecoder(b.NewReader(data)) decoder.UseNumber() - err := decoder.Decode(v) - return err + return decoder.Decode(v) } -func logError(format string, args ...interface{}) { +func logError(format string, args ...any) { log.Errorln(format, args...) - if debugError { + if debugStderr { fmt.Fprintf(os.Stderr, "[ERROR] "+format+"\n", args...) } } diff --git a/core/common_test.go b/core/common_test.go new file mode 100644 index 0000000000..f44bbdc9aa --- /dev/null +++ b/core/common_test.go @@ -0,0 +1,341 @@ +package main + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "strconv" + "testing" + + "github.com/metacubex/mihomo/common/utils" + "github.com/metacubex/mihomo/constant" + cp "github.com/metacubex/mihomo/constant/provider" + "github.com/metacubex/mihomo/tunnel" +) + +func TestDefaultSetupParams(t *testing.T) { + params := defaultSetupParams() + + if params.TestURL != "https://www.gstatic.com/generate_204" { + t.Errorf("TestURL = %s, want the gstatic generate_204 probe", params.TestURL) + } + if params.SelectedMap == nil { + t.Error("SelectedMap must be a usable map so decoding can merge into it") + } +} + +func TestDefaultSetupParamsSurvivesPartialDecode(t *testing.T) { + params := defaultSetupParams() + + if err := json.Unmarshal([]byte(`{"selected-map":{"GLOBAL":"auto"}}`), params); err != nil { + t.Fatalf("decode error: %v", err) + } + + if params.TestURL != "https://www.gstatic.com/generate_204" { + t.Errorf("TestURL = %s, want the default to survive a partial payload", params.TestURL) + } + if params.SelectedMap["GLOBAL"] != "auto" { + t.Errorf("SelectedMap = %v, want GLOBAL mapped to auto", params.SelectedMap) + } +} + +func TestUnmarshalJsonPreservesLargeIntegers(t *testing.T) { + target := map[string]any{} + + if err := UnmarshalJson([]byte(`{"id":9007199254740993}`), &target); err != nil { + t.Fatalf("UnmarshalJson error: %v", err) + } + + number, ok := target["id"].(json.Number) + if !ok { + t.Fatalf("id decoded as %T, want json.Number so int64 precision survives", target["id"]) + } + value, err := number.Int64() + if err != nil { + t.Fatalf("Int64() error: %v", err) + } + if value != 9007199254740993 { + t.Errorf("id = %d, want 9007199254740993", value) + } +} + +func TestUnmarshalJsonReportsInvalidPayload(t *testing.T) { + target := map[string]any{} + + if err := UnmarshalJson([]byte(`{`), &target); err == nil { + t.Fatal("UnmarshalJson accepted malformed JSON") + } +} + +func TestToExternalProviderRejectsUnsupportedProvider(t *testing.T) { + provider, err := toExternalProvider(nil) + + if err == nil { + t.Fatal("toExternalProvider accepted a provider it cannot describe") + } + if provider != nil { + t.Errorf("provider = %+v, want nil alongside the error", provider) + } +} + +func TestMethodCallDecodeArgumentsRejectsEmptyPayload(t *testing.T) { + tests := map[string]json.RawMessage{ + "empty": nil, + "null": json.RawMessage("null"), + } + + for name, arguments := range tests { + t.Run(name, func(t *testing.T) { + call := MethodCall{Method: validateConfigMethod, Arguments: arguments} + target := "" + + err := call.decodeArguments(&target) + + if err == nil { + t.Fatal("decodeArguments accepted a missing payload") + } + if err.Error() != "missing arguments" { + t.Errorf("error = %v, want \"missing arguments\"", err) + } + }) + } +} + +func TestMethodCallDecodeArgumentsAcceptsScalar(t *testing.T) { + call := MethodCall{ + Method: validateConfigMethod, + Arguments: json.RawMessage(`"/tmp/config.yaml"`), + } + target := "" + + if err := call.decodeArguments(&target); err != nil { + t.Fatalf("decodeArguments error: %v", err) + } + if target != "/tmp/config.yaml" { + t.Errorf("target = %s, want /tmp/config.yaml", target) + } +} + +func TestSideLoadParamsMatchesTheWirePayload(t *testing.T) { + params := SideLoadParams{} + + if err := json.Unmarshal([]byte(`{"providerName":"rules","data":"payload"}`), ¶ms); err != nil { + t.Fatalf("decode error: %v", err) + } + + if params.ProviderName != "rules" || params.Data != "payload" { + t.Errorf("params = %+v, want {rules payload}", params) + } +} + +func TestSideUpdateExternalProviderRejectsUnsupportedProvider(t *testing.T) { + if err := sideUpdateExternalProvider(nil, []byte("payload")); err == nil { + t.Fatal("sideUpdateExternalProvider accepted a provider it cannot side-load") + } +} + +func coreMethodConstants(t *testing.T) []CoreMethod { + t.Helper() + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, "constant.go", nil, 0) + if err != nil { + t.Fatalf("parse constant.go: %v", err) + } + + var methods []CoreMethod + ast.Inspect(file, func(node ast.Node) bool { + spec, ok := node.(*ast.ValueSpec) + if !ok { + return true + } + typeName, ok := spec.Type.(*ast.Ident) + if !ok || typeName.Name != "CoreMethod" { + return true + } + for _, value := range spec.Values { + literal, ok := value.(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + continue + } + unquoted, err := strconv.Unquote(literal.Value) + if err != nil { + t.Fatalf("unquote %s: %v", literal.Value, err) + } + methods = append(methods, CoreMethod(unquoted)) + } + return true + }) + + if len(methods) == 0 { + t.Fatal("found no CoreMethod constants; the parser lost track of constant.go") + } + return methods +} + +func TestEveryCoreMethodConstantIsDispatchable(t *testing.T) { + notDispatched := map[CoreMethod]string{ + messageMethod: "core-to-host event envelope, never received", + crashMethod: "handled ahead of the table so it bypasses panic recovery", + updateDnsMethod: "registered by the cgo build only", + } + + for _, method := range coreMethodConstants(t) { + if reason, expected := notDispatched[method]; expected { + if _, exists := methodHandlers[method]; exists { + t.Errorf("method %s has a handler but is documented as %s", method, reason) + } + continue + } + if _, exists := methodHandlers[method]; !exists { + t.Errorf("method %s has no handler and would answer not_implemented", method) + } + } +} + +func TestRegisterMethodRejectsADuplicate(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("registerMethod accepted a second handler for an already routed method") + } + }() + + registerMethod(getProxiesMethod, withoutArguments(func(response MethodResponse) {})) +} + +// fakeProxyProvider and fakeRuleProvider stand in for the mihomo providers the +// tunnel holds. Only the name and the vehicle type matter to the lookup; the +// rest of each interface is here because the tunnel maps are typed. +type fakeProxyProvider struct { + name string + vehicle cp.VehicleType +} + +func (f *fakeProxyProvider) Name() string { return f.name } +func (f *fakeProxyProvider) VehicleType() cp.VehicleType { return f.vehicle } +func (f *fakeProxyProvider) Type() cp.ProviderType { return cp.Proxy } +func (f *fakeProxyProvider) Initial() error { return nil } +func (f *fakeProxyProvider) Update() error { return nil } +func (f *fakeProxyProvider) Proxies() []constant.Proxy { return nil } +func (f *fakeProxyProvider) Count() int { return 0 } +func (f *fakeProxyProvider) Touch() {} +func (f *fakeProxyProvider) HealthCheck() {} +func (f *fakeProxyProvider) Version() uint32 { return 0 } +func (f *fakeProxyProvider) RegisterHealthCheckTask(string, utils.IntRanges[uint16], string, uint) { +} +func (f *fakeProxyProvider) HealthCheckURL() string { return "" } + +type fakeRuleProvider struct { + name string + vehicle cp.VehicleType +} + +func (f *fakeRuleProvider) Name() string { return f.name } +func (f *fakeRuleProvider) VehicleType() cp.VehicleType { return f.vehicle } +func (f *fakeRuleProvider) Type() cp.ProviderType { return cp.Rule } +func (f *fakeRuleProvider) Initial() error { return nil } +func (f *fakeRuleProvider) Update() error { return nil } +func (f *fakeRuleProvider) Behavior() cp.RuleBehavior { return cp.Domain } +func (f *fakeRuleProvider) Count() int { return 0 } +func (f *fakeRuleProvider) Match(*constant.Metadata, constant.RuleMatchHelper) bool { + return false +} +func (f *fakeRuleProvider) Strategy() any { return nil } + +// withTunnelProviders installs a provider set for the duration of a test. The +// unit tests never apply a config, so the tunnel starts empty and restoring it +// to empty is restoring what was there. +func withTunnelProviders( + t *testing.T, + proxyProviders map[string]cp.ProxyProvider, + ruleProviders map[string]cp.RuleProvider, +) { + t.Helper() + tunnel.UpdateProxies(nil, proxyProviders) + tunnel.UpdateRules(nil, nil, ruleProviders) + t.Cleanup(func() { + tunnel.UpdateProxies(nil, nil) + tunnel.UpdateRules(nil, nil, nil) + }) +} + +func TestExternalProvidersSkipsInlineProviders(t *testing.T) { + withTunnelProviders(t, + map[string]cp.ProxyProvider{ + "subscription": &fakeProxyProvider{name: "subscription", vehicle: cp.HTTP}, + "inline": &fakeProxyProvider{name: "inline", vehicle: cp.Compatible}, + }, + map[string]cp.RuleProvider{ + "ruleset": &fakeRuleProvider{name: "ruleset", vehicle: cp.HTTP}, + }, + ) + + providers := externalProviders() + + if _, exist := providers["inline"]; exist { + t.Error("a compatible provider was reported as externally updatable") + } + if len(providers) != 2 { + t.Errorf("externalProviders() = %d entries, want subscription and ruleset", len(providers)) + } +} + +func TestLookupExternalProviderFollowsAConfigReplacement(t *testing.T) { + tunnel.UpdateProxies(nil, map[string]cp.ProxyProvider{ + "first": &fakeProxyProvider{name: "first", vehicle: cp.HTTP}, + }) + t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) + + if _, exist := lookupExternalProvider("first"); !exist { + t.Fatal("the installed provider was not found") + } + + tunnel.UpdateProxies(nil, map[string]cp.ProxyProvider{ + "second": &fakeProxyProvider{name: "second", vehicle: cp.HTTP}, + }) + + if _, exist := lookupExternalProvider("first"); exist { + t.Error("a provider the tunnel no longer holds is still reachable, so updating it writes to disk for nothing") + } + if _, exist := lookupExternalProvider("second"); !exist { + t.Error("the replacement provider is not reachable") + } +} + +func TestLookupExternalProviderRejectsInlineAndUnknownNames(t *testing.T) { + withTunnelProviders(t, + map[string]cp.ProxyProvider{ + "inline": &fakeProxyProvider{name: "inline", vehicle: cp.Compatible}, + }, + nil, + ) + + if _, exist := lookupExternalProvider("inline"); exist { + t.Error("an inline provider is not externally updatable but was handed out") + } + if _, exist := lookupExternalProvider("missing"); exist { + t.Error("an unknown name resolved to a provider") + } +} + +// externalProviders writes rule providers last, so one shadows a proxy provider +// sharing its name. The lookup has to agree with the list the host was given. +func TestLookupExternalProviderMatchesTheListedPrecedence(t *testing.T) { + const shared = "same-name" + ruleProvider := &fakeRuleProvider{name: shared, vehicle: cp.HTTP} + withTunnelProviders(t, + map[string]cp.ProxyProvider{shared: &fakeProxyProvider{name: shared, vehicle: cp.HTTP}}, + map[string]cp.RuleProvider{shared: ruleProvider}, + ) + + found, exist := lookupExternalProvider(shared) + if !exist { + t.Fatal("the shared name resolved to nothing") + } + if found != externalProviders()[shared] { + t.Error("lookupExternalProvider disagrees with the list externalProviders builds") + } + if found != cp.Provider(ruleProvider) { + t.Error("the proxy provider shadowed the rule provider, reversing the listed precedence") + } +} diff --git a/core/config_test.go b/core/config_test.go new file mode 100644 index 0000000000..7fe1b0b255 --- /dev/null +++ b/core/config_test.go @@ -0,0 +1,718 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/metacubex/mihomo/component/updater" + "github.com/metacubex/mihomo/config" + "github.com/metacubex/mihomo/constant" + "github.com/metacubex/mihomo/log" + "github.com/metacubex/mihomo/tunnel" +) + +func withCurrentConfig(t *testing.T, cfg *config.Config) { + t.Helper() + previous := currentConfig + currentConfig = cfg + t.Cleanup(func() { currentConfig = previous }) +} + +func TestRouteConfigCarriesControllerCredentials(t *testing.T) { + cfg := &config.Config{ + General: &config.General{LogLevel: log.DEBUG}, + Controller: &config.Controller{ + ExternalController: "127.0.0.1:9090", + ExternalControllerTLS: "127.0.0.1:9443", + ExternalControllerUnix: "/tmp/flclash.sock", + ExternalControllerPipe: `\\.\pipe\flclash`, + ExternalControllerRoutingMark: 1234, + ExternalDohServer: "/dns-query", + Secret: "s3cret", + Cors: config.Cors{ + AllowOrigins: []string{"https://example.test"}, + AllowPrivateNetwork: true, + }, + }, + TLS: &config.TLS{ + Certificate: "cert", + PrivateKey: "key", + ClientAuthType: "require", + ClientAuthCert: "clientCert", + EchKey: "ech", + }, + } + + got := routeConfig(cfg) + + if got.Secret != "s3cret" { + t.Errorf("Secret = %q, want it preserved; an empty secret unauthenticates the controller", got.Secret) + } + if got.Addr != "127.0.0.1:9090" || got.TLSAddr != "127.0.0.1:9443" { + t.Errorf("addresses = %q/%q, want the controller's own listen addresses", got.Addr, got.TLSAddr) + } + if got.UnixAddr != "/tmp/flclash.sock" || got.PipeAddr != `\\.\pipe\flclash` { + t.Errorf("local addresses = %q/%q, want them preserved", got.UnixAddr, got.PipeAddr) + } + if got.RoutingMark != 1234 || got.DohServer != "/dns-query" { + t.Errorf("routingMark/dohServer = %d/%q, want 1234//dns-query", got.RoutingMark, got.DohServer) + } + if got.Certificate != "cert" || got.PrivateKey != "key" || + got.ClientAuthType != "require" || got.ClientAuthCert != "clientCert" || got.EchKey != "ech" { + t.Errorf("TLS material = %+v, want it carried over from cfg.TLS", got) + } + if len(got.Cors.AllowOrigins) != 1 || got.Cors.AllowOrigins[0] != "https://example.test" || + !got.Cors.AllowPrivateNetwork { + t.Errorf("Cors = %+v, want the configured origins", got.Cors) + } + if !got.IsDebug { + t.Error("IsDebug = false, want it derived from a DEBUG log level") + } +} + +func TestRouteConfigToleratesMissingTLSSection(t *testing.T) { + cfg := &config.Config{ + General: &config.General{}, + Controller: &config.Controller{ExternalController: "127.0.0.1:9090"}, + } + + got := routeConfig(cfg) + + if got.Certificate != "" || got.PrivateKey != "" { + t.Errorf("TLS material = %q/%q, want empty when cfg.TLS is absent", got.Certificate, got.PrivateKey) + } +} + +func TestUpdateConfigRejectsAnUnappliedConfig(t *testing.T) { + withCurrentConfig(t, nil) + + if err := updateConfig(&UpdateParams{}); err != errConfigNotApplied { + t.Errorf("updateConfig error = %v, want errConfigNotApplied", err) + } +} + +func TestHandleUpdateConfigReportsAnUnappliedConfig(t *testing.T) { + withCurrentConfig(t, nil) + + if message := handleUpdateConfig(&UpdateParams{}); message == "" { + t.Error("handleUpdateConfig reported success without an applied config") + } +} + +func TestUpdateConfigAppliesAllowLan(t *testing.T) { + withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}}) + allowLan := true + + if err := updateConfig(&UpdateParams{AllowLan: &allowLan}); err != nil { + t.Fatalf("updateConfig error: %v", err) + } + + if !currentConfig.General.AllowLan { + t.Error("AllowLan stayed false; the patched value never reached the general config") + } +} + +func TestUpdateConfigPatchesOnlyTheTunFieldsItWasGiven(t *testing.T) { + withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}}) + currentConfig.General.Tun.Device = "keep-me" + device := "flclash-tun" + + if err := updateConfig(&UpdateParams{Tun: &tunSchema{Enable: true}}); err != nil { + t.Fatalf("updateConfig error: %v", err) + } + if !currentConfig.General.Tun.Enable { + t.Error("Tun.Enable stayed false") + } + if currentConfig.General.Tun.Device != "keep-me" { + t.Errorf("Tun.Device = %q, want an omitted field left untouched", currentConfig.General.Tun.Device) + } + + if err := updateConfig(&UpdateParams{Tun: &tunSchema{Enable: true, Device: &device}}); err != nil { + t.Fatalf("updateConfig error: %v", err) + } + if currentConfig.General.Tun.Device != device { + t.Errorf("Tun.Device = %q, want %q", currentConfig.General.Tun.Device, device) + } +} + +func TestUpdateConfigLeavesTheControllerAloneWhenTheAddressIsUnchanged(t *testing.T) { + withCurrentConfig(t, &config.Config{ + General: &config.General{}, + Controller: &config.Controller{ExternalController: "127.0.0.1:9090", Secret: "s3cret"}, + }) + address := "127.0.0.1:9090" + + if err := updateConfig(&UpdateParams{ExternalController: &address}); err != nil { + t.Fatalf("updateConfig error: %v", err) + } + + if currentConfig.Controller.Secret != "s3cret" { + t.Errorf("Secret = %q, want the controller left untouched", currentConfig.Controller.Secret) + } +} + +// geoUpdaterCalls counts the reconciliation the code under test asked for. The +// real calls spawn a goroutine that downloads the GEO databases, which is not +// something a unit test should reach for. +type geoUpdaterCalls struct { + registered int + stopped int +} + +func stubGeoUpdater(t *testing.T) *geoUpdaterCalls { + t.Helper() + calls := &geoUpdaterCalls{} + previousRegister, previousStop := registerGeoUpdater, stopGeoUpdater + previousAuto, previousInterval := updater.GeoAutoUpdate(), updater.GeoUpdateInterval() + registerGeoUpdater = func() { calls.registered++ } + stopGeoUpdater = func() { calls.stopped++ } + t.Cleanup(func() { + registerGeoUpdater, stopGeoUpdater = previousRegister, previousStop + updater.SetGeoAutoUpdate(previousAuto) + updater.SetGeoUpdateInterval(previousInterval) + }) + return calls +} + +func TestSyncGeoUpdaterStopsTheUpdaterWhenDisabled(t *testing.T) { + calls := stubGeoUpdater(t) + + updater.SetGeoAutoUpdate(true) + updater.SetGeoUpdateInterval(24) + disabled := false + + syncGeoUpdater(&disabled, nil) + + if updater.GeoAutoUpdate() { + t.Error("GeoAutoUpdate stayed on after the setting was turned off") + } + if updater.GeoUpdateInterval() != 24 { + t.Errorf("GeoUpdateInterval = %d, want the configured 24 left untouched by the stop", updater.GeoUpdateInterval()) + } + if calls.stopped != 1 || calls.registered != 0 { + t.Errorf( + "updater calls = %d registered/%d stopped, want the running updater cancelled", + calls.registered, calls.stopped, + ) + } +} + +func TestSyncGeoUpdaterIgnoresUnchangedParameters(t *testing.T) { + calls := stubGeoUpdater(t) + + updater.SetGeoAutoUpdate(false) + updater.SetGeoUpdateInterval(12) + stillDisabled := false + sameInterval := 12 + + syncGeoUpdater(&stillDisabled, &sameInterval) + + if updater.GeoAutoUpdate() || updater.GeoUpdateInterval() != 12 { + t.Errorf( + "geo settings = %v/%d, want them untouched", + updater.GeoAutoUpdate(), updater.GeoUpdateInterval(), + ) + } + if calls.registered != 0 || calls.stopped != 0 { + t.Errorf( + "updater calls = %d registered/%d stopped, want the running updater left alone", + calls.registered, calls.stopped, + ) + } +} + +// A profile apply reloads the setting out of config.yaml, so the core can end +// up with the updater running while the flag reads off. Reconciling only when +// the flag says on left that goroutine downloading GEO databases forever: the +// flag already matched what the app sent next, so syncGeoUpdater saw nothing to +// change and never cancelled it. +func TestReconcileGeoUpdaterStopsAnUpdaterTheProfileTurnedOff(t *testing.T) { + calls := stubGeoUpdater(t) + + updater.SetGeoAutoUpdate(false) + + reconcileGeoUpdater() + + if calls.stopped != 1 || calls.registered != 0 { + t.Errorf( + "updater calls = %d registered/%d stopped, want the updater cancelled", + calls.registered, calls.stopped, + ) + } +} + +func TestReconcileGeoUpdaterRegistersWhenTheSettingIsOn(t *testing.T) { + calls := stubGeoUpdater(t) + + updater.SetGeoAutoUpdate(true) + + reconcileGeoUpdater() + + if calls.registered != 1 || calls.stopped != 0 { + t.Errorf( + "updater calls = %d registered/%d stopped, want the updater registered", + calls.registered, calls.stopped, + ) + } +} + +func TestCurrentTestURLFallsBackToTheBuiltInProbe(t *testing.T) { + previous := testURL.Load() + t.Cleanup(func() { testURL.Store(previous) }) + + testURL.Store(nil) + if got := currentTestURL(); got != defaultTestURL { + t.Errorf("currentTestURL = %q, want the built-in probe", got) + } + + empty := "" + testURL.Store(&empty) + if got := currentTestURL(); got != defaultTestURL { + t.Errorf("currentTestURL = %q, want an empty setting to fall back", got) + } + + custom := "https://example.test/generate_204" + setTestURL(custom) + if got := currentTestURL(); got != custom { + t.Errorf("currentTestURL = %q, want %q", got, custom) + } +} + +func TestDelayTestSlotsBoundConcurrency(t *testing.T) { + if cap(delayTestSlots) != delayTestConcurrency { + t.Fatalf("delayTestSlots capacity = %d, want %d", cap(delayTestSlots), delayTestConcurrency) + } + + for i := 0; i < delayTestConcurrency; i++ { + if !acquireDelayTestSlot(context.Background()) { + t.Fatalf("slot %d was refused while the semaphore still had room", i) + } + } + + blocked := make(chan struct{}) + go func() { + acquireDelayTestSlot(context.Background()) + close(blocked) + }() + + select { + case <-blocked: + t.Fatal("acquireDelayTestSlot handed out more slots than the configured concurrency") + case <-time.After(20 * time.Millisecond): + } + + releaseDelayTestSlot() + select { + case <-blocked: + case <-time.After(time.Second): + t.Fatal("a released slot never reached the waiting caller") + } + + for i := 0; i < delayTestConcurrency; i++ { + releaseDelayTestSlot() + } +} + +func TestHandleUpdateGeoDataRejectsAnUnknownResource(t *testing.T) { + if message := handleUpdateGeoData("NOPE"); message == "" { + t.Error("an unknown geo resource reported success and silently did nothing") + } + if _, exists := geoResourceUpdaters["MMDB"]; !exists { + t.Error("MMDB is missing from the geo resource table") + } +} + +func TestSetTestURLIgnoresAnEmptyValue(t *testing.T) { + const configured = "https://example.test/generate_204" + previousDefault := constant.DefaultTestURL + previousStored := testURL.Load() + t.Cleanup(func() { + constant.DefaultTestURL = previousDefault + testURL.Store(previousStored) + }) + + setTestURL(configured) + setTestURL("") + + if constant.DefaultTestURL != configured { + t.Errorf("DefaultTestURL = %q, an empty setup param cleared mihomo's own default", constant.DefaultTestURL) + } + if got := currentTestURL(); got != configured { + t.Errorf("currentTestURL() = %q, want %q", got, configured) + } +} + +func TestTestDelayRejectsAnUnknownProxyWithoutWaitingForASlot(t *testing.T) { + for i := 0; i < delayTestConcurrency; i++ { + delayTestSlots <- struct{}{} + } + t.Cleanup(func() { + for i := 0; i < delayTestConcurrency; i++ { + <-delayTestSlots + } + }) + + done := make(chan *Delay, 1) + go func() { + done <- handleTestDelay(&TestDelayParams{ProxyName: "missing", Timeout: 1}) + }() + + select { + case delay := <-done: + if delay.Value != -1 { + t.Errorf("value = %d, want -1", delay.Value) + } + case <-time.After(time.Second): + t.Fatal("an unknown proxy queued behind a saturated delay-test semaphore") + } +} + +func settleMessageBatcher() { + time.Sleep(3 * messageBatchInterval) +} + +func withGeoResourceUpdaters(t *testing.T, updaters map[string]func() error) { + t.Helper() + previous := geoResourceUpdaters + geoResourceUpdaters = updaters + t.Cleanup(func() { + geoResourceUpdaters = previous + for name := range updaters { + releaseGeoUpdate(name) + } + }) +} + +func TestHandleUpdateGeoDataRunsOneUpdatePerResource(t *testing.T) { + const resource = "TEST" + started := make(chan struct{}, 2) + release := make(chan struct{}) + var calls atomic.Int32 + + withGeoResourceUpdaters(t, map[string]func() error{ + resource: func() error { + calls.Add(1) + started <- struct{}{} + <-release + return nil + }, + }) + + if message := handleUpdateGeoData(resource); message != "" { + t.Fatalf("handleUpdateGeoData = %q, want no error", message) + } + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("the first update never started") + } + + if message := handleUpdateGeoData(resource); message != "" { + t.Fatalf("the duplicate request reported %q", message) + } + select { + case <-started: + t.Fatal("a second update started while the first was still running; both close the mmap'd database") + case <-time.After(100 * time.Millisecond): + } + close(release) + + if got := calls.Load(); got != 1 { + t.Errorf("the updater ran %d times, want 1", got) + } + + deadline := time.Now().Add(time.Second) + for !claimGeoUpdate(resource) { + if time.Now().After(deadline) { + t.Fatal("the in-flight claim was never released, so the resource can never be updated again") + } + time.Sleep(5 * time.Millisecond) + } + releaseGeoUpdate(resource) +} + +func TestGeoUpdateHookBlocksAManualUpdateOfTheSameResource(t *testing.T) { + const resource = "MMDB" + ran := make(chan struct{}, 1) + + withGeoResourceUpdaters(t, map[string]func() error{ + resource: func() error { + ran <- struct{}{} + return nil + }, + }) + + updater.GeoUpdateHook(resource, true, false, nil) + + if message := handleUpdateGeoData(resource); message != "" { + t.Fatalf("handleUpdateGeoData = %q, want no error", message) + } + select { + case <-ran: + t.Fatal("a manual update ran while the auto updater was already updating the same resource") + case <-time.After(100 * time.Millisecond): + } + + updater.GeoUpdateHook(resource, false, false, nil) + + if !claimGeoUpdate(resource) { + t.Fatal("the hook never cleared the claim, so the resource stays blocked for the rest of the process") + } + releaseGeoUpdate(resource) + settleMessageBatcher() +} + +// The hook fires for the automatic updater, which can start and finish inside a +// manual update of the same resource. Releasing the manual claim there lets a +// second manual update run alongside the first. +func TestGeoUpdateHookDoesNotReleaseAManualClaim(t *testing.T) { + const resource = "GEOIP" + started := make(chan struct{}, 2) + release := make(chan struct{}) + var calls atomic.Int32 + + withGeoResourceUpdaters(t, map[string]func() error{ + resource: func() error { + calls.Add(1) + started <- struct{}{} + <-release + return nil + }, + }) + + if message := handleUpdateGeoData(resource); message != "" { + t.Fatalf("handleUpdateGeoData = %q, want no error", message) + } + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("the manual update never started") + } + + // An automatic pass over the same resource, start to finish, while the + // manual one is still running. + updater.GeoUpdateHook(resource, true, false, nil) + updater.GeoUpdateHook(resource, false, false, nil) + + if message := handleUpdateGeoData(resource); message != "" { + t.Fatalf("the duplicate request reported %q", message) + } + select { + case <-started: + t.Fatal("a second manual update started while the first was still running") + case <-time.After(100 * time.Millisecond): + } + close(release) + + deadline := time.Now().Add(time.Second) + for !claimGeoUpdate(resource) { + if time.Now().After(deadline) { + t.Fatal("the manual claim was never released") + } + time.Sleep(5 * time.Millisecond) + } + releaseGeoUpdate(resource) + if got := calls.Load(); got != 1 { + t.Errorf("the updater ran %d times, want 1", got) + } + settleMessageBatcher() +} + +// An empty config.yaml is how the app expresses "no profile selected". mihomo's +// own reader rejects it. +func TestLoadConfigTreatsAnEmptyProfileAsTheDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := loadConfig(path) + + if err != nil { + t.Fatalf("loadConfig = %v, want no error for an empty profile", err) + } + if cfg == nil { + t.Fatal("loadConfig returned no config for an empty profile") + } +} + +func TestLoadConfigReportsAMissingFile(t *testing.T) { + _, err := loadConfig(filepath.Join(t.TempDir(), "absent.yaml")) + + if err == nil { + t.Fatal("loadConfig accepted a path that does not exist") + } + if !strings.Contains(err.Error(), "absent.yaml") { + t.Errorf("loadConfig = %v, want it to name the missing file", err) + } +} + +func TestLoadConfigReportsMalformedYaml(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("proxies: [unterminated\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + if _, err := loadConfig(path); err == nil { + t.Fatal("loadConfig accepted malformed yaml") + } +} + +func withSetupConfig(t *testing.T, apply func(*SetupParams) error) { + t.Helper() + previous := setupConfig + setupConfig = apply + t.Cleanup(func() { setupConfig = previous }) + + isInit.Store(true) + isRunning.Store(true) + t.Cleanup(func() { + isInit.Store(false) + isRunning.Store(false) + }) +} + +// applyConfig rolls back to the default config when the profile fails to parse. +// That rollback is the whole recovery: the error reaches the host and the +// listeners keep serving while the user picks another profile. +func TestHandleSetupConfigKeepsTheListenerWhenTheProfileFails(t *testing.T) { + withSetupConfig(t, func(*SetupParams) error { return errors.New("bad profile") }) + + got := handleSetupConfig(defaultSetupParams()) + + if got != "bad profile" { + t.Fatalf("handleSetupConfig = %q, want the apply error", got) + } + if !isRunning.Load() { + t.Error("a failed apply stopped the listeners") + } +} + +func TestHandleSetupConfigLeavesTheListenerAloneOnSuccess(t *testing.T) { + withSetupConfig(t, func(*SetupParams) error { return nil }) + + got := handleSetupConfig(defaultSetupParams()) + + if got != "" { + t.Fatalf("handleSetupConfig = %q, want no error", got) + } + if !isRunning.Load() { + t.Error("a successful apply stopped the listeners") + } +} + +// The host abandons a delay test after a fixed time. +func TestAcquireDelayTestSlotGivesUpOnTheDeadline(t *testing.T) { + for i := 0; i < delayTestConcurrency; i++ { + delayTestSlots <- struct{}{} + } + t.Cleanup(func() { + for i := 0; i < delayTestConcurrency; i++ { + <-delayTestSlots + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + refused := make(chan bool, 1) + go func() { refused <- acquireDelayTestSlot(ctx) }() + + select { + case granted := <-refused: + if granted { + t.Fatal("acquireDelayTestSlot handed out a slot the semaphore did not have") + } + case <-time.After(time.Second): + t.Fatal("acquireDelayTestSlot ignored the deadline and kept queueing") + } +} + +func TestDelayTestTimeoutFallsBackWhenUnset(t *testing.T) { + if got := delayTestTimeout(250); got != 250*time.Millisecond { + t.Errorf("delayTestTimeout(250) = %v, want 250ms", got) + } + for _, unset := range []int64{0, -1} { + if got := delayTestTimeout(unset); got != defaultDelayTestTimeout { + t.Errorf("delayTestTimeout(%d) = %v, want the default %v", unset, got, defaultDelayTestTimeout) + } + } +} + +func TestTestDelayQueuesWhenTheTimeoutIsUnset(t *testing.T) { + for i := 0; i < delayTestConcurrency; i++ { + delayTestSlots <- struct{}{} + } + t.Cleanup(func() { + for i := 0; i < delayTestConcurrency; i++ { + <-delayTestSlots + } + }) + + tunnel.UpdateProxies(map[string]constant.Proxy{"queued": namedProxy("queued")}, nil) + t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) + + done := make(chan *Delay, 1) + go func() { + // A refused port rather than the default URL: the probe this is proving + // will eventually happen should not leave the machine. + done <- handleTestDelay(&TestDelayParams{ + ProxyName: "queued", + TestUrl: "http://127.0.0.1:1", + }) + }() + + select { + case <-done: + t.Fatal("handleTestDelay gave up immediately on an unset timeout, want it queueing for the default") + case <-time.After(200 * time.Millisecond): + } + + // Hand over a slot and join, so nothing is still reading the tunnel when + // the cleanup below replaces it. tunnel.Proxies and tunnel.Providers are + // unsynchronised reads of maps UpdateProxies writes, so an unjoined reader + // is a reported race whatever the timing. + <-delayTestSlots + delay := <-done + delayTestSlots <- struct{}{} + + if delay.Value != -1 { + t.Errorf("value = %d, want -1 for a probe against a refused port", delay.Value) + } +} + +func TestTestDelayStopsQueueingOnceTheTimeoutIsSpent(t *testing.T) { + for i := 0; i < delayTestConcurrency; i++ { + delayTestSlots <- struct{}{} + } + t.Cleanup(func() { + for i := 0; i < delayTestConcurrency; i++ { + <-delayTestSlots + } + }) + + // A proxy the lookup actually resolves, so the call reaches the semaphore + // instead of bailing out on an unknown name. + tunnel.UpdateProxies(map[string]constant.Proxy{"queued": namedProxy("queued")}, nil) + t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) + + done := make(chan *Delay, 1) + go func() { + done <- handleTestDelay(&TestDelayParams{ProxyName: "queued", Timeout: 20}) + }() + + select { + case delay := <-done: + if delay != nil { + t.Errorf("delay = %+v, want no result for a test that never got a slot", delay) + } + case <-time.After(time.Second): + t.Fatal("handleTestDelay outlived the timeout it was given, waiting for a slot") + } +} diff --git a/core/constant.go b/core/constant.go index f7ae1a4453..12df058631 100644 --- a/core/constant.go +++ b/core/constant.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "github.com/metacubex/mihomo/adapter/provider" P "github.com/metacubex/mihomo/component/process" "github.com/metacubex/mihomo/constant" @@ -29,10 +28,8 @@ type UpdateParams struct { Mode *tunnel.TunnelMode `json:"mode"` LogLevel *log.LogLevel `json:"log-level"` IPv6 *bool `json:"ipv6"` - Sniffing *bool `json:"sniffing"` TCPConcurrent *bool `json:"tcp-concurrent"` ExternalController *string `json:"external-controller"` - Interface *string `json:"interface-name"` UnifiedDelay *bool `json:"unified-delay"` GeoAutoUpdate *bool `json:"geo-auto-update"` GeoUpdateInterval *int `json:"geo-update-interval"` @@ -47,6 +44,11 @@ type tunSchema struct { RouteAddress *[]netip.Prefix `yaml:"route-address" json:"route-address,omitempty"` } +type SideLoadParams struct { + ProviderName string `json:"providerName"` + Data string `json:"data"` +} + type ChangeProxyParams struct { GroupName string `json:"group-name"` ProxyName string `json:"proxy-name"` @@ -98,7 +100,6 @@ const ( closeConnectionMethod CoreMethod = "closeConnection" getExternalProvidersMethod CoreMethod = "getExternalProviders" getExternalProviderMethod CoreMethod = "getExternalProvider" - getCountryCodeMethod CoreMethod = "getCountryCode" getMemoryMethod CoreMethod = "getMemory" updateGeoDataMethod CoreMethod = "updateGeoData" updateExternalProviderMethod CoreMethod = "updateExternalProvider" @@ -126,7 +127,7 @@ type Delay struct { type Message struct { Type MessageType `json:"type"` - Data interface{} `json:"data"` + Data any `json:"data"` } const ( @@ -143,8 +144,3 @@ type GeoUpdateStatus struct { Skipped bool `json:"skipped,omitempty"` Error string `json:"error,omitempty"` } - -func (message *Message) Json() (string, error) { - data, err := json.Marshal(message) - return string(data), err -} diff --git a/core/dial_pipe.go b/core/dial_pipe.go index 6acccb2f2e..be1351f35e 100644 --- a/core/dial_pipe.go +++ b/core/dial_pipe.go @@ -1,13 +1,13 @@ -//go:build windows && !cgo +//go:build windows && !(android && cgo) package main import ( - "io" + "net" "github.com/Microsoft/go-winio" ) -func dial(path string) (io.ReadWriteCloser, error) { +func dial(path string) (net.Conn, error) { return winio.DialPipe(path, nil) } diff --git a/core/dial_socket.go b/core/dial_socket.go index 47d58e1ef6..90f845a65d 100644 --- a/core/dial_socket.go +++ b/core/dial_socket.go @@ -1,15 +1,14 @@ -//go:build !cgo && !windows +//go:build !(android && cgo) && !windows package main import ( "fmt" - "io" "net" "strconv" ) -func dial(arg string) (io.ReadWriteCloser, error) { +func dial(arg string) (net.Conn, error) { _, err := strconv.Atoi(arg) if err != nil { return net.Dial("unix", arg) diff --git a/core/go.mod b/core/go.mod index fc40d0cf7c..3937a0b4d3 100644 --- a/core/go.mod +++ b/core/go.mod @@ -7,8 +7,6 @@ replace github.com/metacubex/mihomo => ./Clash.Meta require ( github.com/Microsoft/go-winio v0.6.2 github.com/metacubex/mihomo v0.0.0-00010101000000-000000000000 - golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e - golang.org/x/sync v0.11.0 ) require ( @@ -16,7 +14,6 @@ require ( github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect github.com/ajg/form v1.7.1 // indirect github.com/akutz/memconn v0.1.0 // indirect - github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect @@ -25,7 +22,7 @@ require ( github.com/coreos/go-iptables v0.8.0 // indirect github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dunglas/httpsfv v1.0.2 // indirect - github.com/enfein/mieru/v3 v3.34.0 // indirect + github.com/enfein/mieru/v3 v3.35.0 // indirect github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 // indirect github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 // indirect @@ -37,7 +34,6 @@ require ( github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect - github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect github.com/gofrs/uuid/v5 v5.4.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/snappy v1.0.0 // indirect @@ -45,7 +41,6 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/huin/goupnp v1.3.0 // indirect github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 // indirect github.com/josharian/native v1.1.0 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect @@ -55,51 +50,53 @@ require ( github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 // indirect - github.com/metacubex/amneziawg-go v0.0.0-20260612143004-19b4f1cdd5ec // indirect + github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832 // indirect github.com/metacubex/ascon v0.1.0 // indirect - github.com/metacubex/bart v0.26.0 // indirect + github.com/metacubex/bart v0.29.0 // indirect github.com/metacubex/bbolt v0.0.0-20260706163408-d4ec34ad7c48 // indirect github.com/metacubex/blake3 v0.1.0 // indirect github.com/metacubex/chacha v0.1.5 // indirect github.com/metacubex/chi v0.1.1 // indirect - github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a // indirect + github.com/metacubex/connect-ip-go v0.0.0-20260727083417-67ccdb0cf771 // indirect github.com/metacubex/cpu v0.1.1 // indirect github.com/metacubex/edwards25519 v1.2.0 // indirect github.com/metacubex/fswatch v0.1.1 // indirect github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 // indirect - github.com/metacubex/gvisor v0.0.0-20251227095601-261ec1326fe8 // indirect + github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22 // indirect github.com/metacubex/hkdf v0.1.0 // indirect github.com/metacubex/hpke v0.1.0 // indirect - github.com/metacubex/http v0.1.6 // indirect - github.com/metacubex/jls-quic-go v0.0.0-20260717074316-85a8decdd355 // indirect - github.com/metacubex/jls-tls v0.0.0-20260716145614-4bf88db633e2 // indirect - github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df // indirect + github.com/metacubex/http v0.1.7 // indirect + github.com/metacubex/jls-quic-go v0.0.0-20260727080412-732f2fc9a34d // indirect + github.com/metacubex/jls-tls v0.0.0-20260723084315-67adc0e2f796 // indirect + github.com/metacubex/jsonv2 v0.0.0-20260721082349-16b4998c8f89 // indirect github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 // indirect github.com/metacubex/mhurl v0.1.0 // indirect + github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13 // indirect github.com/metacubex/mlkem v0.1.0 // indirect github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb // indirect github.com/metacubex/qpack v0.6.0 // indirect - github.com/metacubex/quic-go v0.59.1-0.20260606115121-0662b57ad5bf // indirect + github.com/metacubex/quic-go v0.61.1-0.20260727080200-2548683b76f4 // indirect github.com/metacubex/randv2 v0.2.0 // indirect - github.com/metacubex/restls-client-go v0.1.8 // indirect + github.com/metacubex/restls-client-go v0.1.9 // indirect github.com/metacubex/sevenzip v1.6.4 // indirect github.com/metacubex/sing v0.5.7 // indirect github.com/metacubex/sing-mux v0.3.10 // indirect - github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3 // indirect + github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51 // indirect github.com/metacubex/sing-shadowsocks v0.2.12 // indirect github.com/metacubex/sing-shadowsocks2 v0.2.7 // indirect - github.com/metacubex/sing-tun v0.4.21 // indirect + github.com/metacubex/sing-tun v0.4.22 // indirect github.com/metacubex/sing-vmess v0.2.5 // indirect - github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c // indirect + github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0 // indirect github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 // indirect github.com/metacubex/ssh v0.1.0 // indirect - github.com/metacubex/tailscale v0.0.0-20260711142031-e2257fe61058 // indirect - github.com/metacubex/tailscale-wireguard-go v0.0.0-20260623093519-06ea214022e4 // indirect + github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb // indirect + github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2 // indirect github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c // indirect - github.com/metacubex/tls v0.1.7 // indirect + github.com/metacubex/tls v0.1.8 // indirect github.com/metacubex/utls v1.8.7 // indirect github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f // indirect github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 // indirect + github.com/metacubex/zerotier-go v0.0.0-20260813124750-13fa6f45da5f // indirect github.com/miekg/dns v1.1.63 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mroth/weightedrand/v2 v2.1.0 // indirect @@ -133,9 +130,10 @@ require ( go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.33.0 // indirect + golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.35.0 // indirect - golang.org/x/oauth2 v0.24.0 // indirect + golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/term v0.29.0 // indirect golang.org/x/text v0.22.0 // indirect diff --git a/core/go.sum b/core/go.sum index b8aa979025..fefb4d2318 100644 --- a/core/go.sum +++ b/core/go.sum @@ -10,8 +10,6 @@ github.com/ajg/form v1.7.1 h1:OsnBDzTkrWdrxvEnO68I72ZVGJGNaMwPhoAm0V+llgc= github.com/ajg/form v1.7.1/go.mod h1:HL757PzLyNkj5AIfptT6L+iGNeXTlnrr/oDePGc/y7Q= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= @@ -34,8 +32,8 @@ github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= -github.com/enfein/mieru/v3 v3.34.0 h1:8yeaPORvfSQtdfEH+Arw9wttDyFxkik8My4zFdGu79Y= -github.com/enfein/mieru/v3 v3.34.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= +github.com/enfein/mieru/v3 v3.35.0 h1:AKQT6l9R6xcYaUocpfjqE/vWEvYMxpl+071bhfDXkMY= +github.com/enfein/mieru/v3 v3.35.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I= github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g= @@ -46,6 +44,8 @@ github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 h1:tlDMEdcPRQKBE github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1/go.mod h1:4RfsapbGx2j/vU5xC/5/9qB3kn9Awp1YDiEnN43QrJ4= github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010 h1:fuGucgPk5dN6wzfnxl3D0D3rVLw4v2SbBT9jb4VnxzA= github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010/go.mod h1:JtBcj7sBuTTRupn7c2bFspMDIObMJsVK8TeUvpShPok= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -62,8 +62,6 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= -github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= -github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -81,8 +79,6 @@ github.com/google/tink/go v1.6.1 h1:t7JHqO8Ath2w2ig5vjwQYJzhGEZymedQc90lQXUBa4I= github.com/google/tink/go v1.6.1/go.mod h1:IGW53kTgag+st5yPhKKwJ6u2l+SSp5/v9XF7spovjlY= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= -github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 h1:q3OEI9RaN/wwcx+qgGo6ZaoJkCiDYe/gjDLfq7lQQF4= github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905/go.mod h1:VvGYjkZoJyKqlmT1yzakUs4mfKMNB0XdODP0+rdml6k= github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= @@ -96,8 +92,8 @@ github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/4 github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/reedsolomon v1.12.3 h1:tzUznbfc3OFwJaTebv/QdhnFf2Xvb7gZ24XaHLBPmdc= github.com/klauspost/reedsolomon v1.12.3/go.mod h1:3K5rXwABAvzGeR01r6pWZieUALXO/Tq7bFKGIb4m4WI= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= @@ -106,12 +102,12 @@ github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 h1:LqWr0vb9zDNuQS+jJd4fnRYk/SEI7KJ7TDe/L4WFK48= github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78/go.mod h1:BTBG/iVY7rg3qq5WdVCg0GFk58CSvCDSbjy8I7kEx/c= -github.com/metacubex/amneziawg-go v0.0.0-20260612143004-19b4f1cdd5ec h1:nRHevF7PmvDKjkYPjQCU7NUfVrr3Sry4QOPxpqoyo8U= -github.com/metacubex/amneziawg-go v0.0.0-20260612143004-19b4f1cdd5ec/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY= +github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832 h1:mAIVFc8Z9jziImzNl5bXUBVU2bTEmP2sXfosn/6YxPs= +github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY= github.com/metacubex/ascon v0.1.0 h1:6ZWxmXYszT1XXtwkf6nxfFhc/OTtQ9R3Vyj1jN32lGM= github.com/metacubex/ascon v0.1.0/go.mod h1:eV5oim4cVPPdEL8/EYaTZ0iIKARH9pnhAK/fcT5Kacc= -github.com/metacubex/bart v0.26.0 h1:d/bBTvVatfVWGfQbiDpYKI1bXUJgjaabB2KpK1Tnk6w= -github.com/metacubex/bart v0.26.0/go.mod h1:DCcyfP4MC+Zy7sLK7XeGuMw+P5K9mIRsYOBgiE8icsI= +github.com/metacubex/bart v0.29.0 h1:yVc/lLdDqn+7FShI0qUghStHl0/d3mn4EH/I5R5niKk= +github.com/metacubex/bart v0.29.0/go.mod h1:DCcyfP4MC+Zy7sLK7XeGuMw+P5K9mIRsYOBgiE8icsI= github.com/metacubex/bbolt v0.0.0-20260706163408-d4ec34ad7c48 h1:D+AoQ7g/ZeZAHAapelrZB0xUNRcDGuoNLcGmHCnC9Ss= github.com/metacubex/bbolt v0.0.0-20260706163408-d4ec34ad7c48/go.mod h1:yOfzykxfYIEVOtEys6KOKwxy75M2I/KtOQ+p3Kzc8/k= github.com/metacubex/blake3 v0.1.0 h1:KGnjh/56REO7U+cgZA8dnBhxdP7jByrG7hTP+bu6cqY= @@ -120,8 +116,8 @@ github.com/metacubex/chacha v0.1.5 h1:fKWMb/5c7ZrY8Uoqi79PPFxl+qwR7X/q0OrsAubyX2 github.com/metacubex/chacha v0.1.5/go.mod h1:Djn9bPZxLTXbJFSeyo0/qzEzQI+gUSSzttuzZM75GH8= github.com/metacubex/chi v0.1.1 h1:GajmCIXSIPAZNxqTTYpkCrkY9suBz9wjIdgPcoQ5K1A= github.com/metacubex/chi v0.1.1/go.mod h1:/CvDXe8jZD/ecU8Y7fmS5XKLRR44UDmC6/IBkRIb6Z4= -github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a h1:Ph5UfTWDsGruZ+v95Df1ycTflQFmpZBFg2LUvj2kx/M= -github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a/go.mod h1:xYC8Ik7/rN6no+vTRuWMEziGwm3brA0wNM/zZP9qhOQ= +github.com/metacubex/connect-ip-go v0.0.0-20260727083417-67ccdb0cf771 h1:nLGBvwQ2vmsD+gU/XWBQH3xaYCM3vQCJIqPfw3mc/L0= +github.com/metacubex/connect-ip-go v0.0.0-20260727083417-67ccdb0cf771/go.mod h1:9FjDcopUc+tgUva3dauAVzrYPHd0rant+zkvyECzkMg= github.com/metacubex/cpu v0.1.1 h1:rRV5HGmeuGzjiKI3hYbL0dCd0qGwM7VUtk4ICXD06mI= github.com/metacubex/cpu v0.1.1/go.mod h1:09VEt4dSRLR+bOA8l4w4NDuzGZ8n5dkMv7e8axgEeTU= github.com/metacubex/edwards25519 v1.2.0 h1:pIQZLBsjQgg3Nl/c86YYFEUAbL5qQRnPq4LrgIw0KK4= @@ -130,72 +126,76 @@ github.com/metacubex/fswatch v0.1.1 h1:jqU7C/v+g0qc2RUFgmAOPoVvfl2BXXUXEumn6oQux github.com/metacubex/fswatch v0.1.1/go.mod h1:czrTT7Zlbz7vWft8RQu9Qqh+JoX+Nnb+UabuyN1YsgI= github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 h1:cjd4biTvOzK9ubNCCkQ+ldc4YSH/rILn53l/xGBFHHI= github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759/go.mod h1:UHOv2xu+RIgLwpXca7TLrXleEd4oR3sPatW6IF8wU88= -github.com/metacubex/gvisor v0.0.0-20251227095601-261ec1326fe8 h1:hUL81H0Ic/XIDkvtn9M1pmfDdfid7JzYQToY4Ps1TvQ= -github.com/metacubex/gvisor v0.0.0-20251227095601-261ec1326fe8/go.mod h1:8LpS0IJW1VmWzUm3ylb0e2SK5QDm5lO/2qwWLZgRpBU= +github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22 h1:C4d4/BA8WuQmJnSpjy0UN4vJvRMjU3133rxIYQgbPPs= +github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22/go.mod h1:mBJW3UXUusd8ZYO5M6mig0uScIey9iIubgdIiQAk2ug= github.com/metacubex/hkdf v0.1.0 h1:fPA6VzXK8cU1foc/TOmGCDmSa7pZbxlnqhl3RNsthaA= github.com/metacubex/hkdf v0.1.0/go.mod h1:3seEfds3smgTAXqUGn+tgEJH3uXdsUjOiduG/2EtvZ4= github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ= github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U= -github.com/metacubex/http v0.1.6 h1:xvXuvXMCMxCWMF5nEJF4yiKvXL+p2atWMzs37e80m1I= -github.com/metacubex/http v0.1.6/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg= -github.com/metacubex/jls-quic-go v0.0.0-20260717074316-85a8decdd355 h1:DD4qraoIY3IbkHxaBbpJR68sWNzrdKHfFRfFhT6m1W8= -github.com/metacubex/jls-quic-go v0.0.0-20260717074316-85a8decdd355/go.mod h1:dbfkDUKqohALRmgUrVIOLirCNfKjgaos9nfTnBRTpS8= -github.com/metacubex/jls-tls v0.0.0-20260716145614-4bf88db633e2 h1:n+qiR8opOJEerewq/S92zGoHBDXZb+dKmyYq0aKJtis= -github.com/metacubex/jls-tls v0.0.0-20260716145614-4bf88db633e2/go.mod h1:mmqs889W/TqPlfNRDa2UyJvRiLyiTJIEnWHkcj3SKB8= -github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df h1:S0vBzqjXok24VopstOgPd1JdgglW9tXehrqvwpQWbQ8= -github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df/go.mod h1:F4sVXat6QjPXkNsKRDyyG3BhSkxPFFnRPEIwmmyCgbg= +github.com/metacubex/http v0.1.7 h1:dEaQmijUaR/2QKqgjBUmPZCRuh1zBfDqB6ZRNLwvbHg= +github.com/metacubex/http v0.1.7/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg= +github.com/metacubex/jls-quic-go v0.0.0-20260727080412-732f2fc9a34d h1:OF3TUGKdHRrRMp7nb0Pa72QYwKPol9NqSy2yTcP2Sog= +github.com/metacubex/jls-quic-go v0.0.0-20260727080412-732f2fc9a34d/go.mod h1:dH8StPlpZ7atSUuTnfTnRXvJK22nOtQVimNwQ9Gmk58= +github.com/metacubex/jls-tls v0.0.0-20260723084315-67adc0e2f796 h1:1iI4np/Dm1ztCfTW2LoN166O6n728HXMc11aIazYdps= +github.com/metacubex/jls-tls v0.0.0-20260723084315-67adc0e2f796/go.mod h1:mmqs889W/TqPlfNRDa2UyJvRiLyiTJIEnWHkcj3SKB8= +github.com/metacubex/jsonv2 v0.0.0-20260721082349-16b4998c8f89 h1:v41RWXQbmSD1wqOuoN5VvRHo78wuSziG/bbsLdeH2TQ= +github.com/metacubex/jsonv2 v0.0.0-20260721082349-16b4998c8f89/go.mod h1:F4sVXat6QjPXkNsKRDyyG3BhSkxPFFnRPEIwmmyCgbg= github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 h1:hJwCVlE3ojViC35MGHB+FBr8TuIf3BUFn2EQ1VIamsI= github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604/go.mod h1:lpmN3m269b3V5jFCWtffqBLS4U3QQoIid9ugtO+OhVc= github.com/metacubex/mhurl v0.1.0 h1:ZdW4Zxe3j3uJ89gNytOazHu6kbHn5owutN/VfXOI8GE= github.com/metacubex/mhurl v0.1.0/go.mod h1:2qpQImCbXoUs6GwJrjuEXKelPyoimsIXr07eNKZdS00= +github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13 h1:aOU4avd9u7uFivubzn+SjFmEDWAkGF+gvZ0ecEdeg64= +github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13/go.mod h1:+bbwALZI0pbi2auSG5A3ptdpV2DZ2eLObziXo+P7oj0= github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I= github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ= github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb h1:wk6mHYPURSUvWcUv72gNP79oiylFsscBSDPJ6ieV6Iw= github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb/go.mod h1:73ZrCfhdkW4F2E2GAlta3km/S2RHhFNogCMtWZV2anQ= github.com/metacubex/qpack v0.6.0 h1:YqClGIMOpiRYLjV1qOs483Od08MdPgRnHjt90FuaAKw= github.com/metacubex/qpack v0.6.0/go.mod h1:lKGSi7Xk94IMvHGOmxS9eIei3bvIqpOAImEBsaOwTkA= -github.com/metacubex/quic-go v0.59.1-0.20260606115121-0662b57ad5bf h1:WvIp5pF+LLZwg0I6555eMVlKFrLrqQqPKob6XW6niyo= -github.com/metacubex/quic-go v0.59.1-0.20260606115121-0662b57ad5bf/go.mod h1:2YEQEvFrZ5V76oynMBDTlN+4fdnSHCa2uNJxv3cm1HU= +github.com/metacubex/quic-go v0.61.1-0.20260727080200-2548683b76f4 h1:7oX6CpHVqoUXNyyt0MTuO5ywd1qVe1S1t3jXWMW7bYc= +github.com/metacubex/quic-go v0.61.1-0.20260727080200-2548683b76f4/go.mod h1:KTmibo7vZo0gCxdFWrPEQ/u/RGt92mJkrXDE2Qg2/AU= github.com/metacubex/randv2 v0.2.0 h1:uP38uBvV2SxYfLj53kuvAjbND4RUDfFJjwr4UigMiLs= github.com/metacubex/randv2 v0.2.0/go.mod h1:kFi2SzrQ5WuneuoLLCMkABtiBu6VRrMrWFqSPyj2cxY= -github.com/metacubex/restls-client-go v0.1.8 h1:0kQ699TWnbK3bWLhCPE0oIiBJLN+errOLQ9Z3/P1lbA= -github.com/metacubex/restls-client-go v0.1.8/go.mod h1:BN/U52vPw7j8VTSh2vleD/MnmVKCov84mS5VcjVHH4g= +github.com/metacubex/restls-client-go v0.1.9 h1:QmLKwVFuAjB6rL9lQNKj8CuwHqGMJjV36oskeBPEtVs= +github.com/metacubex/restls-client-go v0.1.9/go.mod h1:BN/U52vPw7j8VTSh2vleD/MnmVKCov84mS5VcjVHH4g= github.com/metacubex/sevenzip v1.6.4 h1:OIL+DeOeSAbKNsjqxcYUMiarRmX6Kaxakb0GT7E9Oik= github.com/metacubex/sevenzip v1.6.4/go.mod h1:FP3X9bzFKj9wPxifGN9B3w2fIEicMjzKYIGIhnu+1pw= github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM= github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w= github.com/metacubex/sing-mux v0.3.10 h1:r5CuZ/KuwFsEcRRwpLvzLncW4fDzNfmSEcBEWcy/+94= github.com/metacubex/sing-mux v0.3.10/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc= -github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3 h1:PnMby5+kZXTl/CFDHfxMbMTaSRD+uMKMsrDYVQyAmX8= -github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk= +github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51 h1:FpQj1XfYBIm4LAml+LDjvnfjDs20cSDsFmgjThOqmks= +github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk= github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE= github.com/metacubex/sing-shadowsocks v0.2.12/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU= github.com/metacubex/sing-shadowsocks2 v0.2.7 h1:hSuuc0YpsfiqYqt1o+fP4m34BQz4e6wVj3PPBVhor3A= github.com/metacubex/sing-shadowsocks2 v0.2.7/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE= -github.com/metacubex/sing-tun v0.4.21 h1:MjbPYytM240VH70lO182v2Sg/JthMmx6R6z6E2fCR2I= -github.com/metacubex/sing-tun v0.4.21/go.mod h1:g4I/JNplDBhXLF+aQWgFbhNeJPSXQOWS9HvLeNvkgeA= +github.com/metacubex/sing-tun v0.4.22 h1:6ARRJ2BIFD1u4r/DTMNcxaNuGyimfXEeUyD4iFJRaZs= +github.com/metacubex/sing-tun v0.4.22/go.mod h1:9T0SN+FHvzRrn7hgl2kmXy5v8xzzLKW1hITButIPAco= github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE= github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q= -github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c h1:tH9FuQW357zp2xAGzkoZTGpNGMVmEFZov0iV5M2S5ew= -github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c/go.mod h1:eQZDJTx+IH3k4mXqaOJ3VJ9h9ZqOl60F7TLi5wAU51Q= +github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0 h1:4g/ObGTfHaY2zXcFjKOUO9aAFk+yFjxsZfUPQPG6J8c= +github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0/go.mod h1:H27NH3IgI4qk1Wj8kS9pneNMJYEuxFskcQfkfVW9vnw= github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 h1:DK2l6m2Fc85H2BhiAPgbJygiWhesPlfGmF+9Vw6ARdk= github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141/go.mod h1:/yI4OiGOSn0SURhZdJF3CbtPg3nwK700bG8TZLMBvAg= github.com/metacubex/ssh v0.1.0 h1:iGfr99qk/eMHzUnQ/0bTxXT8+8SWqLSHBWDHoAhngzw= github.com/metacubex/ssh v0.1.0/go.mod h1:NUtl0d+/f2cG9ECEpMM8iCVOpmggQlC13oLeDUONDlU= -github.com/metacubex/tailscale v0.0.0-20260711142031-e2257fe61058 h1:TFM/yl2XvyMDQY16GHDxXtuJMa8DGz7roRpY6plxLMk= -github.com/metacubex/tailscale v0.0.0-20260711142031-e2257fe61058/go.mod h1:rDpd7cy7itpPi3Kgb90fUgSnKwMXHYBk0R5DuNRiNNA= -github.com/metacubex/tailscale-wireguard-go v0.0.0-20260623093519-06ea214022e4 h1:ui12xOUPsCAYwzI7+7Hw2mVhrAbWDGNQZcrBiELhGk0= -github.com/metacubex/tailscale-wireguard-go v0.0.0-20260623093519-06ea214022e4/go.mod h1:W00EeoPdd2jbV3+ZUa0m2b7FdKx5B1LFsXvEpy7cToQ= +github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb h1:EE4+ehlygfki5qJvkIYs6zJXhv7WFAX+4cDQi/b6cY8= +github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb/go.mod h1:fMMYScJPYt8Ss3D4VhontA2uV2N9NPZbbXsPP6LgQeo= +github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2 h1:nKyLVJEU4jsLVx+rdqbDUbEKTX/Sdu161AeVoZC9tlY= +github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2/go.mod h1:W00EeoPdd2jbV3+ZUa0m2b7FdKx5B1LFsXvEpy7cToQ= github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c h1:+2C1UshSLzaogLY/IDz1XhOLxjhs3rkhVHB4eCN9Zto= github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw= -github.com/metacubex/tls v0.1.7 h1:7YW+7z2xGH1bozSg04q3gKNd7GBSiTSXIrQY2SMnfoE= -github.com/metacubex/tls v0.1.7/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM= +github.com/metacubex/tls v0.1.8 h1:BHF2payjtxC7wdR3tnq8vl3FAgEQB6/pPjP2tJNTsP4= +github.com/metacubex/tls v0.1.8/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM= github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs= github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f h1:FGBPRb1zUabhPhDrlKEjQ9lgIwQ6cHL4x8M9lrERhbk= github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f/go.mod h1:oPGcV994OGJedmmxrcK9+ni7jUEMGhR+uVQAdaduIP4= github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 h1:lhlqpYHopuTLx9xQt22kSA9HtnyTDmk5XjjQVCGHe2E= github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49/go.mod h1:MBeEa9IVBphH7vc3LNtW6ZujVXFizotPo3OEiHQ+TNU= +github.com/metacubex/zerotier-go v0.0.0-20260813124750-13fa6f45da5f h1:OS35JZobxU+AUv+9EjH/ULiKwyGciSK98n1CmxitDFo= +github.com/metacubex/zerotier-go v0.0.0-20260813124750-13fa6f45da5f/go.mod h1:8LxQupX5KaZ6lIcBMdaJ3u5xx7+OIRyq87uyyFA1Yto= github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= @@ -221,6 +221,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs= github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= @@ -303,10 +305,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= -golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -315,7 +314,6 @@ golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -340,5 +338,5 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -software.sslmate.com/src/go-pkcs12 v0.2.1 h1:tbT1jjaeFOF230tzOIRJ6U5S1jNqpsSyNjzDd58H3J8= -software.sslmate.com/src/go-pkcs12 v0.2.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/core/hub.go b/core/hub.go index ac3e8a3641..f7f91bc958 100644 --- a/core/hub.go +++ b/core/hub.go @@ -3,61 +3,63 @@ package main import ( "cmp" "context" + "errors" + "net" + "net/url" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "slices" + "strconv" + "sync" + "time" + "github.com/metacubex/mihomo/adapter" "github.com/metacubex/mihomo/adapter/outboundgroup" "github.com/metacubex/mihomo/common/observable" "github.com/metacubex/mihomo/common/utils" - "github.com/metacubex/mihomo/component/mmdb" "github.com/metacubex/mihomo/component/resolver" "github.com/metacubex/mihomo/component/updater" "github.com/metacubex/mihomo/config" "github.com/metacubex/mihomo/constant" "github.com/metacubex/mihomo/constant/features" - cp "github.com/metacubex/mihomo/constant/provider" "github.com/metacubex/mihomo/hub/executor" "github.com/metacubex/mihomo/listener" "github.com/metacubex/mihomo/log" "github.com/metacubex/mihomo/tunnel" "github.com/metacubex/mihomo/tunnel/statistic" - "golang.org/x/exp/slices" - "net" - "os" - "path/filepath" - "runtime" - "runtime/debug" - "strconv" - "sync/atomic" - "time" ) var ( - isInit atomic.Bool - externalProviders = map[string]cp.Provider{} - logSubscriber observable.Subscription[log.Event] + logMu sync.Mutex + logSubscriber observable.Subscription[log.Event] + logCancel context.CancelFunc ) func handleInitClash(params *InitParams) bool { - runLock.Lock() - defer runLock.Unlock() - version = params.Version + configMu.Lock() + defer configMu.Unlock() + sdkVersion.Store(int32(params.Version)) constant.SetHomeDir(params.HomeDir) + initOwnership(params.HomeDir) isInit.Store(true) return true } func handleStartListener() bool { - runLock.Lock() - defer runLock.Unlock() - isRunning = true - updateListeners() + configMu.Lock() + defer configMu.Unlock() + isRunning.Store(true) + updateListeners(currentConfig) resolver.ResetConnection() return true } func handleStopListener() bool { - runLock.Lock() - defer runLock.Unlock() - isRunning = false + configMu.Lock() + defer configMu.Unlock() + isRunning.Store(false) listener.StopListener() resolver.ResetConnection() return true @@ -69,6 +71,7 @@ func handleGetIsInit() bool { func handleForceGC() { log.Infoln("[APP] request force GC") + tunnel.InvalidateAllProxies() runtime.GC() if features.Android { debug.FreeOSMemory() @@ -76,97 +79,129 @@ func handleForceGC() { } func handleShutdown() bool { - stopListeners() + handleStopLog() + + configMu.Lock() + isRunning.Store(false) + listener.StopListener() + updater.StopGeoUpdater() executor.Shutdown() - handleForceGC() + currentConfig = nil isInit.Store(false) + configMu.Unlock() + + handleForceGC() return true } func handleValidateConfig(path string) string { - buf, err := readFile(path) - _, err = config.UnmarshalRawConfig(buf) + buf, err := os.ReadFile(path) if err != nil { return err.Error() } + if _, err = config.UnmarshalRawConfig(buf); err != nil { + return err.Error() + } return "" } -func handleGetProxies() ProxiesData { - runLock.Lock() - defer runLock.Unlock() - - nameList := config.GetProxyNameList() +const globalProxyName = "GLOBAL" - proxies := tunnel.AllProxies() +func isProxyGroupType(adapterType constant.AdapterType) bool { + switch adapterType { + case constant.Selector, constant.URLTest, constant.Fallback, constant.Relay, constant.LoadBalance: + return true + default: + return false + } +} +func proxyGroupNames( + nameList []string, + typeOf func(name string) (constant.AdapterType, bool), +) []string { hasGlobal := false - - allNames := make([]string, 0, len(nameList)+1) + names := make([]string, 0, len(nameList)+1) for _, name := range nameList { - if name == "GLOBAL" { + if name == globalProxyName { hasGlobal = true } - - p, ok := proxies[name] - if !ok || p == nil { + adapterType, ok := typeOf(name) + if !ok || !isProxyGroupType(adapterType) { continue } - switch p.Type() { - case constant.Selector, constant.URLTest, constant.Fallback, constant.Relay, constant.LoadBalance: - allNames = append(allNames, name) - default: - } + names = append(names, name) } if !hasGlobal { - if p, ok := proxies["GLOBAL"]; ok && p != nil { - allNames = append([]string{"GLOBAL"}, allNames...) + if adapterType, ok := typeOf(globalProxyName); ok && isProxyGroupType(adapterType) { + names = append([]string{globalProxyName}, names...) } } + return names +} + +func handleGetProxies() ProxiesData { + proxies := tunnel.AllProxies() + + allNames := proxyGroupNames(config.GetProxyNameList(), func(name string) (constant.AdapterType, bool) { + p, ok := proxies[name] + if !ok || p == nil { + return 0, false + } + return p.Type(), true + }) + return ProxiesData{ All: allNames, Proxies: proxies, } } -func handleChangeProxy(params *ChangeProxyParams, fn func(string string)) { - runLock.Lock() - go func() { - defer runLock.Unlock() - groupName := params.GroupName - proxyName := params.ProxyName - proxies := tunnel.AllProxies() - group, ok := proxies[groupName] - if !ok { - fn("Not found group") - return - } - adapterProxy, ok := group.(*adapter.Proxy) - if !ok { - fn("Group has invalid proxy type") - return - } - selector, ok := adapterProxy.ProxyAdapter.(outboundgroup.SelectAble) - if !ok { - fn("Group is not selectable") - return - } - if proxyName == "" { - selector.ForceSet(proxyName) - } else { - err := selector.Set(proxyName) - if err != nil { - fn(err.Error()) - return - } - } +var ( + errGroupNotFound = errors.New("Not found group") + errGroupInvalidType = errors.New("Group has invalid proxy type") + errGroupNotSelect = errors.New("Group is not selectable") +) - fn("") - return - }() +func lookupProxy(name string) constant.Proxy { + return tunnel.AllProxies()[name] +} + +func selectableGroup(groupName string) (outboundgroup.SelectAble, error) { + group := lookupProxy(groupName) + if group == nil { + return nil, errGroupNotFound + } + adapterProxy, ok := group.(*adapter.Proxy) + if !ok { + return nil, errGroupInvalidType + } + selector, ok := adapterProxy.ProxyAdapter.(outboundgroup.SelectAble) + if !ok { + return nil, errGroupNotSelect + } + return selector, nil +} + +func handleChangeProxy(params *ChangeProxyParams) string { + selectMu.Lock() + defer selectMu.Unlock() + + selector, err := selectableGroup(params.GroupName) + if err != nil { + return err.Error() + } + if params.ProxyName == "" { + selector.ForceSet(params.ProxyName) + return "" + } + if err := selector.Set(params.ProxyName); err != nil { + return err.Error() + } + return "" } func handleGetTraffic(onlyStatisticsProxy bool) Traffic { @@ -189,80 +224,73 @@ func handleResetTraffic() { statistic.DefaultManager.ResetStatistic() } -func handleAsyncTestDelay(params *TestDelayParams, fn func(*Delay)) { - batchKey := params.ProxyName + "\x00" + params.TestUrl - mBatch.Go(batchKey, func() (bool, error) { - testUrl := params.TestUrl - if testUrl == "" { - testUrl = constant.DefaultTestURL - } - delayData := &Delay{ - Name: params.ProxyName, - Url: testUrl, - Value: -1, - } +func delayValue(delay uint16) int32 { + if delay == 0 { + return -1 + } + return int32(delay) +} - expectedStatus, err := utils.NewUnsignedRanges[uint16]("") - if err != nil { - fn(delayData) - return false, nil - } +var anyDelayTestStatus utils.IntRanges[uint16] - ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*time.Duration(params.Timeout)) - defer cancel() +func delayTestTimeout(milliseconds int64) time.Duration { + if milliseconds <= 0 { + return defaultDelayTestTimeout + } + return time.Duration(milliseconds) * time.Millisecond +} - proxies := tunnel.AllProxies() - proxy := proxies[params.ProxyName] +func handleTestDelay(params *TestDelayParams) *Delay { + url := params.TestUrl + if url == "" { + url = currentTestURL() + } + delayData := &Delay{ + Name: params.ProxyName, + Url: url, + Value: -1, + } - if proxy == nil { - fn(delayData) - return false, nil - } - delay, err := proxy.URLTest(ctx, testUrl, expectedStatus) - if err != nil || delay == 0 { - fn(delayData) - return false, nil - } + proxy := lookupProxy(params.ProxyName) + if proxy == nil { + return delayData + } - delayData.Value = int32(delay) - fn(delayData) - return false, nil - }) + ctx, cancel := context.WithTimeout(context.Background(), delayTestTimeout(params.Timeout)) + defer cancel() + + if !acquireDelayTestSlot(ctx) { + return nil + } + defer releaseDelayTestSlot() + + delay, err := proxy.URLTest(ctx, url, anyDelayTestStatus) + if err != nil { + return delayData + } + + delayData.Value = delayValue(delay) + return delayData } func handleGetConnections() *statistic.Snapshot { - runLock.Lock() - defer runLock.Unlock() return statistic.DefaultManager.Snapshot() } func handleCloseConnections() bool { - runLock.Lock() - defer runLock.Unlock() - closeConnections() - return true -} - -func closeConnections() { statistic.DefaultManager.Range(func(c statistic.Tracker) bool { - err := c.Close() - if err != nil { - return false - } + _ = c.Close() return true }) + return true } func handleResetConnections() bool { - runLock.Lock() - defer runLock.Unlock() resolver.ResetConnection() return true } func handleCloseConnection(connectionId string) bool { - runLock.Lock() - defer runLock.Unlock() c := statistic.DefaultManager.Get(connectionId) if c == nil { return false @@ -272,11 +300,9 @@ func handleCloseConnection(connectionId string) bool { } func handleGetExternalProviders() []ExternalProvider { - runLock.Lock() - defer runLock.Unlock() - externalProviders = getExternalProvidersRaw() - eps := make([]ExternalProvider, 0) - for _, p := range externalProviders { + providers := externalProviders() + eps := make([]ExternalProvider, 0, len(providers)) + for _, p := range providers { externalProvider, err := toExternalProvider(p) if err != nil { continue @@ -290,72 +316,172 @@ func handleGetExternalProviders() []ExternalProvider { } func handleGetExternalProvider(externalProviderName string) *ExternalProvider { - runLock.Lock() - defer runLock.Unlock() - externalProvider, exist := externalProviders[externalProviderName] + p, exist := lookupExternalProvider(externalProviderName) if !exist { return nil } - e, err := toExternalProvider(externalProvider) + externalProvider, err := toExternalProvider(p) if err != nil { return nil } - return e + return externalProvider } -func handleUpdateGeoData(geoType string) { - go func() { - switch geoType { - case "MMDB": - updater.UpdateMMDB() - return - case "ASN": - updater.UpdateASN() - return - case "GEOIP": - updater.UpdateGeoIp() - return - case "GEOSITE": - updater.UpdateGeoSite() - return - } - }() +var geoResourceUpdaters = map[string]func() error{ + "MMDB": updater.UpdateMMDB, + "ASN": updater.UpdateASN, + "GEOIP": updater.UpdateGeoIp, + "GEOSITE": updater.UpdateGeoSite, } -func handleUpdateExternalProvider(providerName string, fn func(value string)) { - go func() { - runLock.Lock() - externalProvider, exist := externalProviders[providerName] - runLock.Unlock() - if !exist { - fn("external provider is not exist") - return - } - err := externalProvider.Update() - if err != nil { - fn(err.Error()) - return +const ( + geoUpdateScope = "geo:" + providerUpdateScope = "provider:" +) + +var ( + updateMu sync.Mutex + updateInFlight = map[string]bool{} + geoHookClaims = map[string]bool{} +) + +func claimUpdate(key string) bool { + updateMu.Lock() + defer updateMu.Unlock() + if updateInFlight[key] { + return false + } + updateInFlight[key] = true + return true +} + +func releaseUpdate(key string) { + updateMu.Lock() + defer updateMu.Unlock() + delete(updateInFlight, key) + delete(geoHookClaims, key) +} + +func claimGeoUpdate(geoType string) bool { + return claimUpdate(geoUpdateScope + geoType) +} + +func releaseGeoUpdate(geoType string) { + releaseUpdate(geoUpdateScope + geoType) +} + +func claimGeoUpdateFromHook(geoType string) { + key := geoUpdateScope + geoType + updateMu.Lock() + defer updateMu.Unlock() + if updateInFlight[key] { + return + } + updateInFlight[key] = true + geoHookClaims[key] = true +} + +func releaseGeoUpdateFromHook(geoType string) { + key := geoUpdateScope + geoType + updateMu.Lock() + defer updateMu.Unlock() + if !geoHookClaims[key] { + return + } + delete(geoHookClaims, key) + delete(updateInFlight, key) +} + +func handleUpdateGeoData(geoType string) string { + update, exist := geoResourceUpdaters[geoType] + if !exist { + logError("updateGeoData: unknown geo resource %q", geoType) + return "unknown geo resource: " + geoType + } + if !claimGeoUpdate(geoType) { + return "" + } + safeGoDetached("updateGeoData("+geoType+")", func() { + defer releaseGeoUpdate(geoType) + if err := update(); err != nil { + logError("updateGeoData(%s) error: %v", geoType, err) } - fn("") - }() + }) + return "" } -func handleSideLoadExternalProvider(providerName string, data []byte, fn func(value string)) { - go func() { - runLock.Lock() - defer runLock.Unlock() - externalProvider, exist := externalProviders[providerName] - if !exist { - fn("external provider is not exist") - return +func providerRequestErrorCode(err error) string { + message := err.Error() + if len(message) >= 4 && message[3] == ' ' { + status, parseErr := strconv.Atoi(message[:3]) + if parseErr == nil && status >= 100 && status <= 599 { + return "request_bad_response" } - err := sideUpdateExternalProvider(externalProvider, data) - if err != nil { - fn(err.Error()) - return + } + var urlError *url.Error + var networkError net.Error + if errors.Is(err, context.DeadlineExceeded) || + errors.As(err, &urlError) || + errors.As(err, &networkError) { + return "request_error" + } + return "" +} + +func providerMethodError(code, providerName string, err error) *MethodError { + return &MethodError{ + Code: code, + Message: err.Error(), + Details: map[string]any{"providerName": providerName}, + } +} + +func handleUpdateExternalProvider(providerName string) *MethodError { + p, exist := lookupExternalProvider(providerName) + if !exist { + return providerMethodError( + "provider_not_found", + providerName, + errors.New("external provider does not exist"), + ) + } + key := providerUpdateScope + providerName + if !claimUpdate(key) { + return nil + } + defer releaseUpdate(key) + if err := p.Update(); err != nil { + code := providerRequestErrorCode(err) + if code == "" { + code = "provider_update_error" } - fn("") - }() + return providerMethodError(code, providerName, err) + } + return nil +} + +func handleSideLoadExternalProvider(providerName string, data []byte) *MethodError { + p, exist := lookupExternalProvider(providerName) + if !exist { + return providerMethodError( + "provider_not_found", + providerName, + errors.New("external provider does not exist"), + ) + } + key := providerUpdateScope + providerName + if !claimUpdate(key) { + return providerMethodError( + "provider_updating", + providerName, + errors.New("external provider is updating"), + ) + } + defer releaseUpdate(key) + if err := sideUpdateExternalProvider(p, data); err != nil { + return providerMethodError("provider_update_error", providerName, err) + } + return nil } func handleSuspend(suspended bool) bool { @@ -368,65 +494,74 @@ func handleSuspend(suspended bool) bool { } func handleStartLog() { - runLock.Lock() + logMu.Lock() + if logCancel != nil { + logCancel() + logCancel = nil + } if logSubscriber != nil { log.UnSubscribe(logSubscriber) + logSubscriber = nil } + ctx, cancel := context.WithCancel(context.Background()) subscriber := log.Subscribe() logSubscriber = subscriber - runLock.Unlock() + logCancel = cancel + logMu.Unlock() + go func() { - for logData := range subscriber { - if logData.LogLevel < log.Level() { - continue + defer func() { + logMu.Lock() + if logSubscriber == subscriber { + log.UnSubscribe(subscriber) + logSubscriber = nil + logCancel = nil } - message := &Message{ - Type: LogMessage, - Data: logData, + logMu.Unlock() + }() + for { + select { + case <-ctx.Done(): + return + case logData, ok := <-subscriber: + if !ok { + return + } + if logData.LogLevel < log.Level() { + continue + } + sendMessage(Message{ + Type: LogMessage, + Data: logData, + }) } - sendMessage(*message) } }() } func handleStopLog() { - runLock.Lock() - defer runLock.Unlock() + logMu.Lock() + defer logMu.Unlock() + if logCancel != nil { + logCancel() + logCancel = nil + } if logSubscriber != nil { log.UnSubscribe(logSubscriber) logSubscriber = nil } } -func handleGetCountryCode(ip string, fn func(value string)) { - go func() { - runLock.Lock() - defer runLock.Unlock() - codes := mmdb.IPInstance().LookupCode(net.ParseIP(ip)) - if len(codes) == 0 { - fn("") - return - } - fn(codes[0]) - }() -} - -func handleGetMemory(fn func(value uint64)) { - go func() { - fn(statistic.DefaultManager.Memory()) - }() +func handleGetMemory() uint64 { + return statistic.DefaultManager.Memory() } func handleGetConfig(path string) (*config.RawConfig, error) { - bytes, err := readFile(path) - if err != nil { - return nil, err - } - prof, err := config.UnmarshalRawConfig(bytes) + buf, err := os.ReadFile(path) if err != nil { return nil, err } - return prof, nil + return config.UnmarshalRawConfig(buf) } func handleCrash() { @@ -434,46 +569,47 @@ func handleCrash() { } func handleUpdateConfig(params *UpdateParams) string { - updateConfig(params) + if err := updateConfig(params); err != nil { + return err.Error() + } return "" } +// providerPaths derives the providers root and the directory belonging to one +// profile. +// +// The profile ID is an int64 rendered through strconv, so the last element can +// never carry a separator or a `..` — that is what keeps handleClearEffect from +// becoming a general-purpose privileged file deletion API. +func providerPaths(homeDir string, profileId int64) (root string, target string) { + root = filepath.Join(homeDir, "profiles", "providers") + return root, filepath.Join(root, strconv.FormatInt(profileId, 10)) +} + // handleClearEffect derives the provider directory from a profile ID so the // method cannot be used as a general-purpose privileged file deletion API. -func handleClearEffect(profileId int64, response MethodResponse) { - go func() { - if !isInit.Load() { - response.success("not initialized") - return - } - if profileId <= 0 { - response.success("invalid profile id") - return - } - providersRoot := filepath.Join( - constant.Path.HomeDir(), - "profiles", - "providers", - ) - providersPath := filepath.Join( - providersRoot, - strconv.FormatInt(profileId, 10), - ) - if err := os.RemoveAll(providersPath); err != nil { - response.success(err.Error()) - return - } - _ = os.Remove(providersRoot) - response.success("") - }() +func handleClearEffect(profileId int64) string { + if !isInit.Load() { + return "not initialized" + } + if profileId <= 0 { + return "invalid profile id" + } + providersRoot, providersPath := providerPaths(constant.Path.HomeDir(), profileId) + if err := os.RemoveAll(providersPath); err != nil { + return err.Error() + } + _ = os.Remove(providersRoot) + return "" } +var setupConfig = applyConfig + func handleSetupConfig(params *SetupParams) string { if !isInit.Load() { return "not initialized" } - err := applyConfig(params) - if err != nil { + if err := setupConfig(params); err != nil { return err.Error() } return "" @@ -481,18 +617,13 @@ func handleSetupConfig(params *SetupParams) string { func init() { adapter.UrlTestHook = func(url string, name string, delay uint16) { - delayData := &Delay{ - Url: url, - Name: name, - } - if delay == 0 { - delayData.Value = -1 - } else { - delayData.Value = int32(delay) - } sendMessage(Message{ Type: DelayMessage, - Data: delayData, + Data: &Delay{ + Url: url, + Name: name, + Value: delayValue(delay), + }, }) } statistic.DefaultRequestNotify = func(c statistic.Tracker) { @@ -502,12 +633,19 @@ func init() { }) } executor.DefaultProviderLoadedHook = func(providerName string) { + scheduleReclaimOwnership() sendMessage(Message{ Type: LoadedMessage, Data: providerName, }) } updater.GeoUpdateHook = func(geoType string, updating bool, skipped bool, updateErr error) { + if updating { + claimGeoUpdateFromHook(geoType) + } else { + releaseGeoUpdateFromHook(geoType) + scheduleReclaimOwnership() + } status := GeoUpdateStatus{ Type: geoType, Updating: updating, diff --git a/core/hub_test.go b/core/hub_test.go new file mode 100644 index 0000000000..b50ee397b7 --- /dev/null +++ b/core/hub_test.go @@ -0,0 +1,738 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/metacubex/mihomo/adapter" + "github.com/metacubex/mihomo/adapter/outbound" + "github.com/metacubex/mihomo/common/utils" + "github.com/metacubex/mihomo/config" + "github.com/metacubex/mihomo/constant" + cp "github.com/metacubex/mihomo/constant/provider" + "github.com/metacubex/mihomo/log" + "github.com/metacubex/mihomo/tunnel" +) + +func namedProxy(name string) constant.Proxy { + return adapter.NewProxy(outbound.NewDirectWithOption(outbound.DirectOption{Name: name})) +} + +// typeMap turns a name -> adapter type table into the lookup proxyGroupNames +// expects, reporting a miss for any name outside the table. +func typeMap(types map[string]constant.AdapterType) func(string) (constant.AdapterType, bool) { + return func(name string) (constant.AdapterType, bool) { + adapterType, ok := types[name] + return adapterType, ok + } +} + +func TestProxyGroupNamesKeepsOnlyGroups(t *testing.T) { + names := proxyGroupNames( + []string{"GLOBAL", "Auto", "Direct node", "Fall", "Balance", "Chain"}, + typeMap(map[string]constant.AdapterType{ + "GLOBAL": constant.Selector, + "Auto": constant.URLTest, + "Direct node": constant.Direct, + "Fall": constant.Fallback, + "Balance": constant.LoadBalance, + "Chain": constant.Relay, + }), + ) + + want := []string{"GLOBAL", "Auto", "Fall", "Balance", "Chain"} + if strings.Join(names, ",") != strings.Join(want, ",") { + t.Fatalf("proxyGroupNames = %v, want %v", names, want) + } +} + +func TestProxyGroupNamesPreservesListOrder(t *testing.T) { + names := proxyGroupNames( + []string{"C", "A", "B"}, + typeMap(map[string]constant.AdapterType{ + "A": constant.Selector, + "B": constant.Selector, + "C": constant.Selector, + }), + ) + + if strings.Join(names, ",") != "C,A,B" { + t.Fatalf("proxyGroupNames = %v, want the config order C,A,B", names) + } +} + +func TestProxyGroupNamesSkipsUnknownNames(t *testing.T) { + names := proxyGroupNames( + []string{"Known", "Missing"}, + typeMap(map[string]constant.AdapterType{"Known": constant.Selector}), + ) + + if len(names) != 1 || names[0] != "Known" { + t.Fatalf("proxyGroupNames = %v, want only the registered name", names) + } +} + +func TestProxyGroupNamesPrependsUnlistedGlobal(t *testing.T) { + names := proxyGroupNames( + []string{"Auto"}, + typeMap(map[string]constant.AdapterType{ + "Auto": constant.URLTest, + "GLOBAL": constant.Selector, + }), + ) + + want := []string{"GLOBAL", "Auto"} + if strings.Join(names, ",") != strings.Join(want, ",") { + t.Fatalf("proxyGroupNames = %v, want %v", names, want) + } +} + +func TestProxyGroupNamesDoesNotDuplicateListedGlobal(t *testing.T) { + names := proxyGroupNames( + []string{"Auto", "GLOBAL"}, + typeMap(map[string]constant.AdapterType{ + "Auto": constant.URLTest, + "GLOBAL": constant.Selector, + }), + ) + + want := []string{"Auto", "GLOBAL"} + if strings.Join(names, ",") != strings.Join(want, ",") { + t.Fatalf("proxyGroupNames = %v, want %v", names, want) + } +} + +func TestProxyGroupNamesOmitsMissingGlobal(t *testing.T) { + names := proxyGroupNames( + []string{"Auto"}, + typeMap(map[string]constant.AdapterType{"Auto": constant.URLTest}), + ) + + if len(names) != 1 || names[0] != "Auto" { + t.Fatalf("proxyGroupNames = %v, want no GLOBAL entry", names) + } +} + +func TestProxyGroupNamesTreatsGlobalLikeAnyOtherName(t *testing.T) { + types := map[string]constant.AdapterType{ + "Auto": constant.URLTest, + "GLOBAL": constant.Direct, + } + + unlisted := proxyGroupNames([]string{"Auto"}, typeMap(types)) + listed := proxyGroupNames([]string{"GLOBAL", "Auto"}, typeMap(types)) + + if strings.Join(unlisted, ",") != "Auto" { + t.Fatalf("unlisted GLOBAL = %v, want it filtered out like a listed one", unlisted) + } + if strings.Join(listed, ",") != "Auto" { + t.Fatalf("listed GLOBAL = %v, want it filtered out", listed) + } +} + +func TestProxyGroupNamesEmptyList(t *testing.T) { + names := proxyGroupNames(nil, typeMap(nil)) + if len(names) != 0 { + t.Fatalf("proxyGroupNames = %v, want empty", names) + } +} + +func TestIsProxyGroupType(t *testing.T) { + groups := []constant.AdapterType{ + constant.Selector, + constant.URLTest, + constant.Fallback, + constant.Relay, + constant.LoadBalance, + } + for _, adapterType := range groups { + if !isProxyGroupType(adapterType) { + t.Errorf("isProxyGroupType(%v) = false, want true", adapterType) + } + } + + singles := []constant.AdapterType{constant.Direct, constant.Reject} + for _, adapterType := range singles { + if isProxyGroupType(adapterType) { + t.Errorf("isProxyGroupType(%v) = true, want false", adapterType) + } + } +} + +func TestDelayValue(t *testing.T) { + tests := []struct { + delay uint16 + want int32 + }{ + {delay: 0, want: -1}, + {delay: 1, want: 1}, + {delay: 250, want: 250}, + {delay: 65535, want: 65535}, + } + for _, test := range tests { + if got := delayValue(test.delay); got != test.want { + t.Errorf("delayValue(%d) = %d, want %d", test.delay, got, test.want) + } + } +} + +func TestProviderPathsStayUnderTheRoot(t *testing.T) { + home := filepath.Join("var", "home") + root, target := providerPaths(home, 1234567890123) + + wantRoot := filepath.Join(home, "profiles", "providers") + if root != wantRoot { + t.Fatalf("root = %q, want %q", root, wantRoot) + } + wantTarget := filepath.Join(wantRoot, "1234567890123") + if target != wantTarget { + t.Fatalf("target = %q, want %q", target, wantTarget) + } +} + +// The ID is an int64 rendered through strconv, so no caller-supplied value can +// add a separator or climb out of the providers root. +func TestProviderPathsCannotEscape(t *testing.T) { + home := t.TempDir() + ids := []int64{1, -1, 0, 1 << 62, -(1 << 62)} + + for _, id := range ids { + root, target := providerPaths(home, id) + cleaned := filepath.Clean(target) + if !strings.HasPrefix(cleaned, root+string(filepath.Separator)) { + t.Errorf("providerPaths(%d) escaped: %q is outside %q", id, cleaned, root) + } + if filepath.Dir(cleaned) != root { + t.Errorf("providerPaths(%d) = %q, want a direct child of %q", id, cleaned, root) + } + } +} + +func TestHandleValidateConfigAcceptsAValidFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("mixed-port: 7890\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + if got := handleValidateConfig(path); got != "" { + t.Fatalf("handleValidateConfig = %q, want no error", got) + } +} + +func TestHandleValidateConfigReportsAMissingFile(t *testing.T) { + got := handleValidateConfig(filepath.Join(t.TempDir(), "absent.yaml")) + + if got == "" { + t.Fatal("handleValidateConfig accepted a path that does not exist") + } + if !strings.Contains(got, "absent.yaml") { + t.Errorf("handleValidateConfig = %q, want it to name the missing file", got) + } +} + +func TestHandleValidateConfigReportsMalformedYaml(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("proxies: [unterminated\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + if got := handleValidateConfig(path); got == "" { + t.Fatal("handleValidateConfig accepted malformed yaml") + } +} + +// The provider entry has to win, because that is the one handleGetProxies +// reports under the shared name and the one the host is asking about. +func TestLookupProxyPrefersTheProviderEntry(t *testing.T) { + base := namedProxy("shared") + fromProvider := newCachingProvider("subscription", "shared", "node-a") + + tunnel.UpdateProxies( + map[string]constant.Proxy{"shared": base, "DIRECT": namedProxy("DIRECT")}, + map[string]cp.ProxyProvider{"subscription": fromProvider}, + ) + t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) + + if got := lookupProxy("shared"); got == base { + t.Error("lookupProxy(shared) returned the base entry, want the provider one") + } + if got := lookupProxy("node-a"); got == nil { + t.Error("lookupProxy(node-a) = nil, want the provider entry") + } + if got := lookupProxy("DIRECT"); got != base && got == nil { + t.Error("lookupProxy(DIRECT) = nil, want the base entry") + } + if got := lookupProxy("missing"); got != nil { + t.Errorf("lookupProxy(missing) = %v, want nil", got) + } +} + +// A subscription refresh has to reach a delay test without a config apply, +// which is the whole reason lookupProxy may read a cache at all. +func TestLookupProxyFollowsAProviderUpdate(t *testing.T) { + provider := newCachingProvider("subscription", "old-node") + withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil) + + if lookupProxy("old-node") == nil { + t.Fatal("lookupProxy(old-node) = nil before the update") + } + + provider.setProxies("new-node") + + if lookupProxy("new-node") == nil { + t.Error("lookupProxy(new-node) = nil, a subscription refresh did not reach the lookup") + } + if got := lookupProxy("old-node"); got != nil { + t.Errorf("lookupProxy(old-node) = %v after the refresh dropped it, want nil", got) + } +} + +func TestHandleShutdownTearsDownBackgroundWork(t *testing.T) { + withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}}) + isInit.Store(true) + + cancelled := false + logMu.Lock() + logSubscriber = make(chan log.Event) + logCancel = func() { cancelled = true } + logMu.Unlock() + + handleShutdown() + + if currentConfig != nil { + t.Error("currentConfig survived shutdown, so updateConfig would still patch a dead config") + } + if isInit.Load() { + t.Error("isInit stayed true after shutdown") + } + + logMu.Lock() + subscriber, cancel := logSubscriber, logCancel + logMu.Unlock() + if subscriber != nil || cancel != nil { + t.Error("shutdown left the log stream subscribed, so events keep being pumped to a host that stopped the core") + } + if !cancelled { + t.Error("shutdown never cancelled the log pump") + } +} + +// The write itself touches one field on one group. +func TestHandleChangeProxyDoesNotWaitOutAConfigApply(t *testing.T) { + configMu.Lock() + defer configMu.Unlock() + + answered := make(chan string, 1) + go func() { + answered <- handleChangeProxy(&ChangeProxyParams{GroupName: "absent", ProxyName: "node"}) + }() + + select { + case message := <-answered: + if message != errGroupNotFound.Error() { + t.Errorf("message = %q, want %q", message, errGroupNotFound) + } + case <-time.After(time.Second): + t.Fatal("handleChangeProxy queued behind configMu, so selecting a node waits for the whole apply") + } +} + +// The exclusion it does need has to survive: patchSelectGroup and +// handleChangeProxy both write group selections with no lock inside mihomo. +func TestPatchSelectGroupSerialisesWithProxyChanges(t *testing.T) { + selectMu.Lock() + + patched := make(chan struct{}) + go func() { + patchSelectGroup(map[string]string{"group": "node"}) + close(patched) + }() + + select { + case <-patched: + selectMu.Unlock() + t.Fatal("patchSelectGroup wrote selections without taking selectMu") + case <-time.After(20 * time.Millisecond): + } + + selectMu.Unlock() + select { + case <-patched: + case <-time.After(time.Second): + t.Fatal("patchSelectGroup never ran after selectMu was released") + } +} + +// The hoisted constant has to keep meaning what the call meant, so a change to +// mihomo's parser cannot silently narrow which HTTP statuses a delay test +// accepts. +func TestAnyDelayTestStatusMatchesTheEmptyRange(t *testing.T) { + built, err := utils.NewUnsignedRanges[uint16]("") + if err != nil { + t.Fatalf("NewUnsignedRanges(\"\") error: %v", err) + } + if len(built) != len(anyDelayTestStatus) { + t.Fatalf("anyDelayTestStatus = %v, want %v", anyDelayTestStatus, built) + } + for _, status := range []uint16{0, 200, 204, 302, 404, 503} { + if !anyDelayTestStatus.Check(status) { + t.Errorf("status %d was rejected, so a reachable proxy reports as unreachable", status) + } + } +} + +// The host reads the proxy tables — a proxy list for the UI, a provider lookup +// for an update — while a config apply replaces them. Those reads used to be +// serialised against the apply by a lock the host held for both; the tunnel's +// own accessors take none, so under -race this is what proves the reads were +// put back under the lock the apply writes with. +func TestProxyTableReadsAreSerialisedAgainstAnApply(t *testing.T) { + withTunnelProviders(t, nil, nil) + + const rounds = 200 + var wg sync.WaitGroup + stop := make(chan struct{}) + + wg.Add(1) + go func() { + defer wg.Done() + for round := 0; round < rounds; round++ { + name := "subscription-" + strconv.Itoa(round) + tunnel.UpdateProxies( + map[string]constant.Proxy{"DIRECT": namedProxy("DIRECT")}, + map[string]cp.ProxyProvider{name: newCachingProvider(name, "node")}, + ) + tunnel.UpdateRules(nil, nil, map[string]cp.RuleProvider{ + name: &fakeRuleProvider{name: name, vehicle: cp.HTTP}, + }) + } + close(stop) + }() + + for reader := 0; reader < 4; reader++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + tunnel.AllProxies() + externalProviders() + lookupExternalProvider("subscription-0") + } + }() + } + + wg.Wait() +} + +// blockingProxyProvider holds its update open so a second request for the same +// provider can be observed while the first is still running. +type blockingProxyProvider struct { + fakeProxyProvider + started chan struct{} + release chan struct{} + calls atomic.Int32 +} + +type failingProxyProvider struct { + fakeProxyProvider + err error +} + +func (p *failingProxyProvider) Update() error { + return p.err +} + +func TestProviderRequestErrorCode(t *testing.T) { + tests := []struct { + err error + want string + }{ + {err: errors.New("503 Service Unavailable"), want: "request_bad_response"}, + {err: fmt.Errorf("fetch: %w", context.DeadlineExceeded), want: "request_error"}, + {err: errors.New("proxy 0: unsupported type"), want: ""}, + } + for _, test := range tests { + if got := providerRequestErrorCode(test.err); got != test.want { + t.Errorf("providerRequestErrorCode(%q) = %q, want %q", test.err, got, test.want) + } + } +} + +func TestUpdateExternalProviderCategorizesFailure(t *testing.T) { + const name = "subscription" + provider := &failingProxyProvider{ + fakeProxyProvider: fakeProxyProvider{name: name, vehicle: cp.HTTP}, + err: errors.New("proxy 0: unsupported type"), + } + withTunnelProviders(t, map[string]cp.ProxyProvider{name: provider}, nil) + + methodError := handleUpdateExternalProvider(name) + if methodError == nil { + t.Fatal("the provider error was reported as success") + } + if methodError.Code != "provider_update_error" { + t.Errorf("code = %q, want provider_update_error", methodError.Code) + } + if methodError.Message != provider.err.Error() { + t.Errorf("message = %q, want %q", methodError.Message, provider.err) + } + details, ok := methodError.Details.(map[string]any) + if !ok || details["providerName"] != name { + t.Errorf("details = %#v, want providerName %q", methodError.Details, name) + } +} + +func (p *blockingProxyProvider) Update() error { + p.calls.Add(1) + p.started <- struct{}{} + <-p.release + return nil +} + +func TestUpdateExternalProviderRunsOneAtATime(t *testing.T) { + const name = "subscription" + provider := &blockingProxyProvider{ + fakeProxyProvider: fakeProxyProvider{name: name, vehicle: cp.HTTP}, + started: make(chan struct{}, 2), + release: make(chan struct{}, 2), + } + withTunnelProviders(t, map[string]cp.ProxyProvider{name: provider}, nil) + // Released through the cleanup so a blocked update never wedges the run. + t.Cleanup(func() { close(provider.release) }) + + first := make(chan *MethodError, 1) + go func() { first <- handleUpdateExternalProvider(name) }() + + select { + case <-provider.started: + case <-time.After(time.Second): + t.Fatal("the first update never started") + } + + second := make(chan *MethodError, 1) + go func() { second <- handleUpdateExternalProvider(name) }() + + select { + case methodError := <-second: + if methodError != nil { + t.Fatalf("the duplicate request reported %q", methodError.Message) + } + case <-time.After(time.Second): + t.Fatal("the duplicate request reached the provider instead of being coalesced") + } + select { + case <-provider.started: + t.Fatal("a second update started while the first was still running; both rewrite the same vehicle file") + default: + } + + provider.release <- struct{}{} + if methodError := <-first; methodError != nil { + t.Fatalf("the first update reported %q", methodError.Message) + } + + if got := provider.calls.Load(); got != 1 { + t.Errorf("Update ran %d times, want 1", got) + } + if !claimUpdate(providerUpdateScope + name) { + t.Fatal("the in-flight claim was never released, so the provider can never be updated again") + } + releaseUpdate(providerUpdateScope + name) +} + +// cachingProvider counts how often the tunnel walked its proxy list, which is +// exactly what the AllProxies cache exists to avoid. +type cachingProvider struct { + fakeProxyProvider + mu sync.Mutex + proxies []constant.Proxy + version uint32 + reads int +} + +func newCachingProvider(name string, proxyNames ...string) *cachingProvider { + provider := &cachingProvider{ + fakeProxyProvider: fakeProxyProvider{name: name, vehicle: cp.HTTP}, + } + provider.setProxies(proxyNames...) + return provider +} + +func (p *cachingProvider) Proxies() []constant.Proxy { + p.mu.Lock() + defer p.mu.Unlock() + p.reads++ + return p.proxies +} + +func (p *cachingProvider) Version() uint32 { + p.mu.Lock() + defer p.mu.Unlock() + return p.version +} + +// setProxies mirrors mihomo's baseProvider.setProxies: a new list and a bumped +// version, which is the only signal a runtime provider update leaves behind. +func (p *cachingProvider) setProxies(proxyNames ...string) { + p.mu.Lock() + defer p.mu.Unlock() + p.proxies = nil + for _, name := range proxyNames { + p.proxies = append(p.proxies, namedProxy(name)) + } + p.version++ +} + +func (p *cachingProvider) readCount() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.reads +} + +func proxyNamesOf(proxies map[string]constant.Proxy) []string { + names := make([]string, 0, len(proxies)) + for name := range proxies { + names = append(names, name) + } + slices.Sort(names) + return names +} + +func TestAllProxiesServesRepeatedCallsFromCache(t *testing.T) { + provider := newCachingProvider("subscription", "node-a", "node-b") + withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil) + + first := tunnel.AllProxies() + reads := provider.readCount() + second := tunnel.AllProxies() + + if provider.readCount() != reads { + t.Errorf("the provider list was walked again for an unchanged tunnel (%d -> %d reads)", + reads, provider.readCount()) + } + if got, want := proxyNamesOf(second), proxyNamesOf(first); !slices.Equal(got, want) { + t.Errorf("cached answer = %v, want %v", got, want) + } +} + +// executor.ApplyConfig installs the providers (line 100, updateProxies) before +// it loads them (line 115, loadProvider -> Initial -> the fetcher's onUpdate -> +// setProxies), so a provider is in the tunnel with an empty list for as long as +// its subscription takes to parse. Nothing tells the cache the list arrived +// except the version the load bumps, and a read landing inside that window must +// not be what the cache keeps answering with. +func TestAllProxiesPicksUpAProviderThatLoadsAfterTheApply(t *testing.T) { + loading := newCachingProvider("subscription") + base := map[string]constant.Proxy{"DIRECT": namedProxy("DIRECT")} + + tunnel.UpdateProxies(base, map[string]cp.ProxyProvider{"subscription": loading}) + t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) + + // A UI poll landing between the two executor steps. + during := proxyNamesOf(tunnel.AllProxies()) + if !slices.Equal(during, []string{"DIRECT"}) { + t.Fatalf("mid-apply AllProxies = %v, want only the base proxies", during) + } + + // Initial() finished parsing the subscription. + loading.setProxies("node-a", "node-b") + + after := proxyNamesOf(tunnel.AllProxies()) + want := []string{"DIRECT", "node-a", "node-b"} + if !slices.Equal(after, want) { + t.Errorf("AllProxies = %v, want %v; a provider that loaded after the apply did not reach the tunnel", after, want) + } +} + +// forceGC is the host's "give the memory back" hook — Android calls it from +// onLowMemory and handleShutdown ends with it — and a cache the collection +// cannot reach defeats it. +func TestForceGCReleasesTheProxyCache(t *testing.T) { + provider := newCachingProvider("subscription", "node-a", "node-b") + withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil) + + tunnel.AllProxies() + warm := provider.readCount() + tunnel.AllProxies() + if provider.readCount() != warm { + t.Fatal("the cache was not warm, so this proves nothing about releasing it") + } + + handleForceGC() + + tunnel.AllProxies() + if provider.readCount() == warm { + t.Error("AllProxies still answered from cache after a forced GC, so the replaced proxies stay pinned") + } +} + +func TestAllProxiesFollowsAProviderUpdate(t *testing.T) { + provider := newCachingProvider("subscription", "node-a") + withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil) + + tunnel.AllProxies() + provider.setProxies("node-b", "node-c") + + got := proxyNamesOf(tunnel.AllProxies()) + if want := []string{"node-b", "node-c"}; !slices.Equal(got, want) { + t.Errorf("proxies = %v, want %v; a subscription refresh did not reach the tunnel", got, want) + } +} + +// A config apply replaces the maps outright, and the replacements start their +// own version counts — so identical versions across an apply say nothing about +// whether the proxies are the same. Only the invalidation on UpdateProxies +// catches this. +func TestAllProxiesFollowsAConfigApplyThatKeepsEveryVersion(t *testing.T) { + before := newCachingProvider("subscription", "old-node") + tunnel.UpdateProxies(nil, map[string]cp.ProxyProvider{"subscription": before}) + t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) + + tunnel.AllProxies() + + after := newCachingProvider("subscription", "new-node") + if after.Version() != before.Version() { + t.Fatalf("the two providers must share a version for this to test anything (%d vs %d)", + after.Version(), before.Version()) + } + tunnel.UpdateProxies(nil, map[string]cp.ProxyProvider{"subscription": after}) + + got := proxyNamesOf(tunnel.AllProxies()) + if want := []string{"new-node"}; !slices.Equal(got, want) { + t.Errorf("proxies = %v, want %v; the previous profile's nodes survived the apply", got, want) + } +} + +func TestHandleGetProxiesSeesAProviderUpdate(t *testing.T) { + provider := newCachingProvider("subscription", "node-a") + withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil) + + if _, exist := handleGetProxies().Proxies["node-a"]; !exist { + t.Fatal("the handler did not report the installed proxy") + } + + provider.setProxies("node-b") + + data := handleGetProxies() + if _, exist := data.Proxies["node-b"]; !exist { + t.Error("the handler kept serving the pre-refresh proxy list") + } + if _, exist := data.Proxies["node-a"]; exist { + t.Error("a proxy the refresh removed is still reported") + } +} diff --git a/core/ipc_test.go b/core/ipc_test.go new file mode 100644 index 0000000000..4fdeec49ff --- /dev/null +++ b/core/ipc_test.go @@ -0,0 +1,685 @@ +//go:build !cgo + +package main + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "io" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/metacubex/mihomo/common/observable" + "github.com/metacubex/mihomo/log" +) + +type fakeConn struct { + mu sync.Mutex + written bytes.Buffer + readable *bytes.Reader + writeErr error + writeErrAfter int + writeErrTimes int + closed bool + deadlines int +} + +func (fake *fakeConn) Read(p []byte) (int, error) { + if fake.readable == nil { + return 0, io.EOF + } + return fake.readable.Read(p) +} + +func (fake *fakeConn) Write(p []byte) (int, error) { + fake.mu.Lock() + defer fake.mu.Unlock() + if fake.writeErr == nil { + return fake.written.Write(p) + } + writeErr := fake.writeErr + if fake.writeErrTimes > 0 { + fake.writeErrTimes-- + if fake.writeErrTimes == 0 { + fake.writeErr = nil + } + } + accepted := min(fake.writeErrAfter, len(p)) + if accepted <= 0 { + return 0, writeErr + } + fake.writeErrAfter -= accepted + written, err := fake.written.Write(p[:accepted]) + if err != nil { + return written, err + } + return written, writeErr +} + +func (fake *fakeConn) deadlineCount() int { + fake.mu.Lock() + defer fake.mu.Unlock() + return fake.deadlines +} + +func (fake *fakeConn) setWriteErr(err error) { + fake.mu.Lock() + defer fake.mu.Unlock() + fake.writeErr = err + fake.written.Reset() +} + +func (fake *fakeConn) Close() error { + fake.mu.Lock() + defer fake.mu.Unlock() + fake.closed = true + return nil +} + +func (fake *fakeConn) SetWriteDeadline(time.Time) error { + fake.mu.Lock() + defer fake.mu.Unlock() + fake.deadlines++ + return nil +} + +func (fake *fakeConn) isClosed() bool { + fake.mu.Lock() + defer fake.mu.Unlock() + return fake.closed +} + +func (fake *fakeConn) frames(t *testing.T) [][]byte { + t.Helper() + fake.mu.Lock() + defer fake.mu.Unlock() + reader := bytes.NewReader(fake.written.Bytes()) + var frames [][]byte + for { + frame, err := readFrame(reader) + if err == io.EOF { + return frames + } + if err != nil { + t.Fatalf("readFrame error: %v", err) + } + frames = append(frames, frame) + } +} + +// swapConn installs a connection the way send reads one. The message batcher +// runs for the whole test binary and reads conn under connMu, so a bare +// assignment here is a data race against every event it happens to deliver. +func swapConn(next ipcConn) ipcConn { + connMu.Lock() + defer connMu.Unlock() + previous := conn + conn = next + return previous +} + +func captureFrames(t *testing.T, run func()) [][]byte { + t.Helper() + fake := &fakeConn{} + previous := swapConn(fake) + defer swapConn(previous) + run() + return fake.frames(t) +} + +func captureSingleFrame(t *testing.T, run func()) []byte { + t.Helper() + frames := captureFrames(t, run) + if len(frames) != 1 { + t.Fatalf("captured %d frames, want 1", len(frames)) + } + return frames[0] +} + +func TestWriteFrameReadFrameRoundTrip(t *testing.T) { + payloads := [][]byte{ + []byte(""), + []byte("{}"), + bytes.Repeat([]byte("x"), 70000), + } + + buffer := &bytes.Buffer{} + for _, payload := range payloads { + if _, err := writeFrame(buffer, payload); err != nil { + t.Fatalf("writeFrame error: %v", err) + } + } + + for i, payload := range payloads { + got, err := readFrame(buffer) + if err != nil { + t.Fatalf("readFrame %d error: %v", i, err) + } + if !bytes.Equal(got, payload) { + t.Errorf("frame %d length = %d, want %d", i, len(got), len(payload)) + } + } +} + +func TestWriteFrameRejectsOversizedPayload(t *testing.T) { + written, err := writeFrame(&bytes.Buffer{}, make([]byte, maxIPCFrameSize+1)) + + if written != 0 { + t.Errorf("writeFrame wrote %d bytes for a rejected payload, want 0", written) + } + if err == nil { + t.Fatal("writeFrame accepted a payload above the frame limit") + } + if !strings.Contains(err.Error(), "IPC frame exceeds") { + t.Errorf("writeFrame error = %v, want an IPC frame limit error", err) + } +} + +func TestReadFrameRejectsOversizedHeader(t *testing.T) { + header := make([]byte, 4) + binary.LittleEndian.PutUint32(header, maxIPCFrameSize+1) + + _, err := readFrame(bytes.NewReader(header)) + + if err == nil { + t.Fatal("readFrame accepted a header above the frame limit") + } + if !strings.Contains(err.Error(), "IPC frame exceeds") { + t.Errorf("readFrame error = %v, want an IPC frame limit error", err) + } +} + +func TestReadFrameRejectsTruncatedPayload(t *testing.T) { + header := make([]byte, 4) + binary.LittleEndian.PutUint32(header, 8) + truncated := append(header, []byte("abc")...) + + if _, err := readFrame(bytes.NewReader(truncated)); err == nil { + t.Fatal("readFrame accepted a truncated payload") + } +} + +func TestMethodResponseSuccessEnvelope(t *testing.T) { + frame := captureSingleFrame(t, func() { + MethodResponse{ID: "42"}.success(map[string]any{"ok": true}) + }) + + var envelope map[string]any + if err := json.Unmarshal(frame, &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope["id"] != "42" { + t.Errorf("id = %v, want 42", envelope["id"]) + } + if _, hasError := envelope["error"]; hasError { + t.Error("a successful response must omit the error field") + } + result, ok := envelope["result"].(map[string]any) + if !ok || result["ok"] != true { + t.Errorf("result = %v, want {ok: true}", envelope["result"]) + } +} + +func TestMethodResponseFailureEnvelope(t *testing.T) { + frame := captureSingleFrame(t, func() { + MethodResponse{ID: "7"}.failure("core_error", "boom", []string{"detail"}) + }) + + var envelope struct { + ID string `json:"id"` + Result any `json:"result"` + Error *MethodError `json:"error"` + } + if err := json.Unmarshal(frame, &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope.ID != "7" { + t.Errorf("id = %s, want 7", envelope.ID) + } + if envelope.Result != nil { + t.Errorf("result = %v, want null on failure", envelope.Result) + } + if envelope.Error == nil { + t.Fatal("failure response must carry an error") + } + if envelope.Error.Code != "core_error" || envelope.Error.Message != "boom" { + t.Errorf("error = %+v, want code core_error message boom", envelope.Error) + } +} + +func TestDecodeMethodArgumentsRejectsInvalidPayloads(t *testing.T) { + tests := []struct { + name string + arguments string + }{ + {name: "missing", arguments: ""}, + {name: "null", arguments: "null"}, + {name: "wrong type", arguments: `"not-an-object"`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + call := &MethodCall{ + ID: "1", + Method: initClashMethod, + Arguments: json.RawMessage(test.arguments), + } + target := InitParams{} + accepted := true + + frame := captureSingleFrame(t, func() { + accepted = decodeMethodArguments(call, MethodResponse{ID: call.ID}, &target) + }) + + if accepted { + t.Fatal("decodeMethodArguments accepted an invalid payload") + } + var envelope struct { + Error *MethodError `json:"error"` + } + if err := json.Unmarshal(frame, &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope.Error == nil || envelope.Error.Code != "invalid_arguments" { + t.Errorf("error = %+v, want code invalid_arguments", envelope.Error) + } + }) + } +} + +func TestDecodeMethodArgumentsAcceptsValidPayload(t *testing.T) { + call := &MethodCall{ + ID: "1", + Method: initClashMethod, + Arguments: json.RawMessage(`{"home-dir":"/tmp/flclash","version":3}`), + } + target := InitParams{} + + frames := captureFrames(t, func() { + if !decodeMethodArguments(call, MethodResponse{ID: call.ID}, &target) { + t.Fatal("decodeMethodArguments rejected a valid payload") + } + }) + + if len(frames) != 0 { + t.Errorf("a successful decode must not send a response, got %d frames", len(frames)) + } + if target.HomeDir != "/tmp/flclash" || target.Version != 3 { + t.Errorf("decoded params = %+v, want {/tmp/flclash 3}", target) + } +} + +func TestHandleMethodCallReportsUnknownMethod(t *testing.T) { + frame := captureSingleFrame(t, func() { + handleMethodCall( + &MethodCall{ID: "9", Method: CoreMethod("nopeMethod")}, + MethodResponse{ID: "9"}, + ) + }) + + var envelope struct { + Error *MethodError `json:"error"` + } + if err := json.Unmarshal(frame, &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope.Error == nil || envelope.Error.Code != "not_implemented" { + t.Fatalf("error = %+v, want code not_implemented", envelope.Error) + } + if !strings.Contains(envelope.Error.Message, "nopeMethod") { + t.Errorf("error message = %s, want it to name the method", envelope.Error.Message) + } +} + +func TestSendMessageBatchWrapsMessagesInMethodCall(t *testing.T) { + batch := []Message{ + {Type: DelayMessage, Data: Delay{Url: "https://example.test", Name: "a", Value: 12}}, + {Type: LogMessage, Data: "hello"}, + } + + frame := captureSingleFrame(t, func() { + sendMessageBatch(batch) + }) + + call := MethodCall{} + if err := json.Unmarshal(frame, &call); err != nil { + t.Fatalf("batch frame is not a MethodCall: %v", err) + } + if call.Method != messageMethod { + t.Errorf("method = %s, want %s", call.Method, messageMethod) + } + if call.ID != "" { + t.Errorf("id = %s, want an empty id for event calls", call.ID) + } + + var decoded []Message + if err := json.Unmarshal(call.Arguments, &decoded); err != nil { + t.Fatalf("arguments are not a message list: %v", err) + } + if len(decoded) != 2 { + t.Fatalf("decoded %d messages, want 2", len(decoded)) + } + if decoded[0].Type != DelayMessage || decoded[1].Type != LogMessage { + t.Errorf("decoded types = %s,%s, want delay,log", decoded[0].Type, decoded[1].Type) + } +} + +func TestSendWithoutConnectionDoesNotPanic(t *testing.T) { + previous := swapConn(nil) + defer swapConn(previous) + + send([]byte("{}")) +} + +func TestMethodResponseAnswersExactlyOnce(t *testing.T) { + response := newMethodResponse("11", nil) + + frames := captureFrames(t, func() { + response.success("first") + response.success("second") + response.failure("core_error", "late", nil) + }) + + if len(frames) != 1 { + t.Fatalf("captured %d frames, want 1; a second answer double-releases the platform callback", len(frames)) + } + var envelope struct { + Result string `json:"result"` + } + if err := json.Unmarshal(frames[0], &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope.Result != "first" { + t.Errorf("result = %q, want the first answer to win", envelope.Result) + } +} + +func TestSendMessageBatchDoesNotDoubleEncodeArguments(t *testing.T) { + frame := captureSingleFrame(t, func() { + sendMessageBatch([]Message{{Type: LoadedMessage, Data: "provider"}}) + }) + + var envelope struct { + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(frame, &envelope); err != nil { + t.Fatalf("batch frame is not valid JSON: %v", err) + } + if len(envelope.Arguments) == 0 || envelope.Arguments[0] != '[' { + t.Fatalf("arguments = %s, want a JSON array rather than an encoded string", envelope.Arguments) + } +} + +func TestHandleMethodCallReportsMissingArguments(t *testing.T) { + frame := captureSingleFrame(t, func() { + handleMethodCall( + &MethodCall{ID: "3", Method: getTrafficMethod}, + newMethodResponse("3", nil), + ) + }) + + var envelope struct { + Error *MethodError `json:"error"` + } + if err := json.Unmarshal(frame, &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope.Error == nil || envelope.Error.Code != "invalid_arguments" { + t.Errorf("error = %+v, want code invalid_arguments", envelope.Error) + } +} + +func TestSendArmsAWriteDeadlineOnEveryFrame(t *testing.T) { + fake := &fakeConn{} + previous := swapConn(fake) + defer swapConn(previous) + + send([]byte("{}")) + send([]byte("{}")) + + fake.mu.Lock() + defer fake.mu.Unlock() + if fake.deadlines != 2 { + t.Errorf("deadlines armed = %d, want one per frame", fake.deadlines) + } +} + +func TestSendResumesAFrameThatStalledOnTheWriteDeadline(t *testing.T) { + fake := &fakeConn{writeErr: os.ErrDeadlineExceeded, writeErrAfter: 2, writeErrTimes: 1} + previous := swapConn(fake) + defer swapConn(previous) + + send([]byte("{}")) + + if fake.isClosed() { + t.Fatal("a host that stopped reading for one deadline window killed the connection, and with it the tunnel") + } + frames := fake.frames(t) + if len(frames) != 1 || string(frames[0]) != "{}" { + t.Errorf("frames = %q, want the stalled frame finished so the stream stays in sync", frames) + } + if fake.deadlineCount() < 2 { + t.Error("resuming a stalled frame must arm a fresh write deadline") + } +} + +func TestSendDropsTheConnectionWhenAStalledFrameNeverDrains(t *testing.T) { + fake := &fakeConn{writeErr: os.ErrDeadlineExceeded, writeErrAfter: 2} + previous := swapConn(fake) + defer swapConn(previous) + + send([]byte("{}")) + + if !fake.isClosed() { + t.Error("a frame that stalls past every retry leaves the stream desynchronized and must close the connection") + } + if fake.deadlineCount() != 1+ipcPartialFrameRetries { + t.Errorf("deadlines armed = %d, want the retries bounded at %d", fake.deadlineCount(), ipcPartialFrameRetries) + } +} + +func TestSendDropsTheConnectionAfterAPartialWriteFailure(t *testing.T) { + fake := &fakeConn{writeErr: errors.New("host stopped reading"), writeErrAfter: 2} + previous := swapConn(fake) + defer swapConn(previous) + + send([]byte("{}")) + + if !fake.isClosed() { + t.Error("a half-written frame left the connection open; the stream is desynchronized") + } + if conn != nil { + t.Error("conn still points at the dead connection") + } + + send([]byte("{}")) +} + +func TestSendKeepsTheConnectionWhenNoBytesReachedTheWire(t *testing.T) { + fake := &fakeConn{writeErr: os.ErrDeadlineExceeded} + previous := swapConn(fake) + defer swapConn(previous) + + send([]byte("{}")) + + if fake.isClosed() { + t.Error("write backpressure closed the only control channel; the Core would have to be restarted") + } + connMu.Lock() + still := conn == ipcConn(fake) + connMu.Unlock() + if !still { + t.Error("conn was cleared even though the frame never reached the wire") + } + + fake.setWriteErr(nil) + send([]byte("{}")) + if frames := fake.frames(t); len(frames) != 1 { + t.Errorf("delivered %d frames after the stall, want the retried one", len(frames)) + } +} + +func TestSendRearmsTheFailureReportAfterAFrameGetsThrough(t *testing.T) { + fake := &fakeConn{writeErr: os.ErrDeadlineExceeded} + previous := swapConn(fake) + deliveryFailureReported.Store(false) + defer func() { + swapConn(previous) + deliveryFailureReported.Store(false) + }() + + send([]byte("{}")) + if !deliveryFailureReported.Load() { + t.Fatal("the first delivery failure was not reported") + } + + fake.setWriteErr(nil) + send([]byte("{}")) + + if deliveryFailureReported.Load() { + t.Error("a successful frame left the report latched; every later failure stays silent") + } +} + +func TestSafeGoAnswersWhenTheHandlerPanics(t *testing.T) { + response := newMethodResponse("5", nil) + done := make(chan struct{}) + + frames := captureFrames(t, func() { + safeGo(response, func() { + defer close(done) + panic("handler exploded") + }) + <-done + time.Sleep(50 * time.Millisecond) + }) + + if len(frames) != 1 { + t.Fatalf("captured %d frames, want the panic answered exactly once", len(frames)) + } + var envelope struct { + Error *MethodError `json:"error"` + } + if err := json.Unmarshal(frames[0], &envelope); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if envelope.Error == nil || envelope.Error.Code != "internal_error" { + t.Errorf("error = %+v, want code internal_error", envelope.Error) + } + if !strings.Contains(envelope.Error.Message, "handler exploded") { + t.Errorf("error message = %q, want it to carry the panic value", envelope.Error.Message) + } +} + +func TestSafeGoDetachedSurvivesAPanic(t *testing.T) { + done := make(chan struct{}) + + safeGoDetached("test", func() { + defer close(done) + panic("background exploded") + }) + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("safeGoDetached never ran the task") + } + time.Sleep(50 * time.Millisecond) +} + +func drainLogStream(t *testing.T, subscriber observable.Subscription[log.Event], window time.Duration, match func(string) bool) bool { + t.Helper() + deadline := time.After(window) + for { + select { + case event, ok := <-subscriber: + if !ok { + return false + } + if match(event.Payload) { + return true + } + case <-deadline: + return false + } + } +} + +func TestSendFailureIsNotReportedThroughTheLogStream(t *testing.T) { + subscriber := log.Subscribe() + defer log.UnSubscribe(subscriber) + + previous := swapConn(nil) + deliveryFailureReported.Store(false) + defer func() { + swapConn(previous) + deliveryFailureReported.Store(false) + }() + + for i := 0; i < 8; i++ { + send([]byte("{}")) + } + + echoed := drainLogStream(t, subscriber, 100*time.Millisecond, func(payload string) bool { + return strings.Contains(payload, "conn nil") || strings.Contains(payload, "server write") + }) + if echoed { + t.Error("a failed send published a log event, which is batched and handed back to send") + } +} + +func TestLogErrorStillReachesTheLogStream(t *testing.T) { + subscriber := log.Subscribe() + defer log.UnSubscribe(subscriber) + + logError("delivery probe %d", 7) + + reached := drainLogStream(t, subscriber, time.Second, func(payload string) bool { + return strings.Contains(payload, "delivery probe 7") + }) + if !reached { + t.Fatal("logError never reached the log stream, so the send-path assertion above proves nothing") + } +} + +func TestDeliveryFailureIsReportedOncePerConnection(t *testing.T) { + deliveryFailureReported.Store(false) + defer deliveryFailureReported.Store(false) + + logDeliveryError("first") + if !deliveryFailureReported.Load() { + t.Fatal("the first delivery failure did not latch") + } + logDeliveryError("second") + + deliveryFailureReported.Store(false) + logDeliveryError("after a fresh connection") + if !deliveryFailureReported.Load() { + t.Error("the latch did not re-arm for a new connection") + } +} + +func TestHandleStartLogAndStopLogLifecycle(t *testing.T) { + handleStartLog() + logMu.Lock() + if logSubscriber == nil || logCancel == nil { + logMu.Unlock() + t.Fatal("handleStartLog did not initialize subscriber or cancel func") + } + logMu.Unlock() + + handleStartLog() + + handleStopLog() + logMu.Lock() + if logSubscriber != nil || logCancel != nil { + logMu.Unlock() + t.Fatal("handleStopLog did not clear subscriber or cancel func") + } + logMu.Unlock() +} diff --git a/core/lib.go b/core/lib.go index 7082f44374..e2ce8304ec 100644 --- a/core/lib.go +++ b/core/lib.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build android && cgo package main @@ -8,41 +8,55 @@ package main import "C" import ( - "context" "core/platform" t "core/tun" "encoding/json" "errors" + "fmt" + "net" + "strings" + "sync" + "sync/atomic" + "syscall" + "unsafe" + "github.com/metacubex/mihomo/component/dialer" "github.com/metacubex/mihomo/component/process" "github.com/metacubex/mihomo/constant" "github.com/metacubex/mihomo/dns" "github.com/metacubex/mihomo/listener/sing_tun" "github.com/metacubex/mihomo/log" - "golang.org/x/sync/semaphore" - "net" - "strings" - "sync" - "syscall" - "unsafe" ) -var eventListener unsafe.Pointer +var ( + eventListenerLock sync.RWMutex + eventListener unsafe.Pointer +) type TunHandler struct { listener *sing_tun.Listener callback unsafe.Pointer - limit *semaphore.Weighted + mu sync.RWMutex } func (th *TunHandler) start(fd int, stack, address, dns string) { - runLock.Lock() - defer runLock.Unlock() - _ = th.limit.Acquire(context.TODO(), 4) - defer th.limit.Release(4) + configMu.Lock() + defer configMu.Unlock() + + th.mu.Lock() th.initHook() + th.mu.Unlock() + + // t.Start runs outside th.mu on purpose. The hook is live from initHook + // onwards, and a socket opened anywhere inside Start reaches handleProtect + // on this very goroutine — an RLock taken while this one holds the write + // lock deadlocks the start outright. Nothing is lost by dropping it: both + // hooks return early until th.listener is set, which is below. tunListener := t.Start(fd, stack, address, dns) + + th.mu.Lock() + defer th.mu.Unlock() if tunListener != nil { log.Infoln("TUN address: %v", tunListener.Address()) th.listener = tunListener @@ -52,8 +66,8 @@ func (th *TunHandler) start(fd int, stack, address, dns string) { } func (th *TunHandler) close() { - _ = th.limit.Acquire(context.TODO(), 4) - defer th.limit.Release(4) + th.mu.Lock() + defer th.mu.Unlock() th.clear() } @@ -70,8 +84,8 @@ func (th *TunHandler) clear() { } func (th *TunHandler) handleProtect(fd int) { - _ = th.limit.Acquire(context.Background(), 1) - defer th.limit.Release(1) + th.mu.RLock() + defer th.mu.RUnlock() if th.listener == nil { return @@ -81,8 +95,8 @@ func (th *TunHandler) handleProtect(fd int) { } func (th *TunHandler) handleResolveProcess(source, target net.Addr) string { - _ = th.limit.Acquire(context.Background(), 1) - defer th.limit.Release(1) + th.mu.RLock() + defer th.mu.RUnlock() if th.listener == nil { return "" @@ -95,33 +109,55 @@ func (th *TunHandler) handleResolveProcess(source, target net.Addr) string { case "tcp", "tcp4", "tcp6": protocol = syscall.IPPROTO_TCP } - if version < 29 { + if sdkVersion.Load() < 29 { uid = platform.QuerySocketUidFromProcFs(source, target) } return resolveProcess(th.callback, protocol, source.String(), target.String(), uid) } -func (th *TunHandler) initHook() { - dialer.DefaultSocketHook = func(network, address string, conn syscall.RawConn) error { - if platform.ShouldBlockConnection() { - return errBlocked +var ( + installHooksOnce sync.Once + activeTunHandler atomic.Pointer[TunHandler] +) + +func installHooks() { + installHooksOnce.Do(func() { + dialer.DefaultSocketHook = func(network, address string, conn syscall.RawConn) error { + if platform.ShouldBlockConnection() { + return errBlocked + } + th := activeTunHandler.Load() + if th == nil { + return nil + } + return conn.Control(func(fd uintptr) { + th.handleProtect(int(fd)) + }) } - return conn.Control(func(fd uintptr) { - tunHandler.handleProtect(int(fd)) - }) - } - process.DefaultPackageNameResolver = func(metadata *constant.Metadata) (string, error) { - src, dst := metadata.RawSrcAddr, metadata.RawDstAddr - if src == nil || dst == nil { - return "", process.ErrInvalidNetwork + process.DefaultPackageNameResolver = func(metadata *constant.Metadata) (string, error) { + th := activeTunHandler.Load() + if th == nil { + return "", process.ErrInvalidNetwork + } + src, dst := metadata.RawSrcAddr, metadata.RawDstAddr + if src == nil || dst == nil { + return "", process.ErrInvalidNetwork + } + return th.handleResolveProcess(src, dst), nil } - return tunHandler.handleResolveProcess(src, dst), nil - } + }) +} + +func (th *TunHandler) initHook() { + installHooks() + activeTunHandler.Store(th) } +// Swap the handler, never the hook: mihomo nil-checks DefaultSocketHook once and +// dereferences it again when the socket is created, so clearing it mid-dial +// calls a nil func value. func (th *TunHandler) removeHook() { - dialer.DefaultSocketHook = nil - process.DefaultPackageNameResolver = nil + activeTunHandler.CompareAndSwap(th, nil) } var ( @@ -133,76 +169,85 @@ var ( func handleStopTun() { tunLock.Lock() defer tunLock.Unlock() - if tunHandler != nil { - tunHandler.close() + stopTunLocked() +} + +func stopTunLocked() { + if tunHandler == nil { + return } + tunHandler.close() + tunHandler = nil } func handleStartTun(callback unsafe.Pointer, fd int, stack, address, dns string) { - handleStopTun() tunLock.Lock() defer tunLock.Unlock() - if fd != 0 { - tunHandler = &TunHandler{ - callback: callback, - limit: semaphore.NewWeighted(4), + stopTunLocked() + if fd == 0 { + if callback != nil { + releaseObject(callback) } - tunHandler.start(fd, stack, address, dns) + return } + tunHandler = &TunHandler{ + callback: callback, + } + tunHandler.start(fd, stack, address, dns) } +var ( + dnsUpdateMu sync.Mutex + dnsUpdateSeq atomic.Uint64 +) + func handleUpdateDns(value string) { - go func() { + seq := dnsUpdateSeq.Add(1) + safeGoDetached("updateDns", func() { + dnsUpdateMu.Lock() + defer dnsUpdateMu.Unlock() + if seq != dnsUpdateSeq.Load() { + return + } log.Infoln("[DNS] updateDns %s", value) dns.UpdateSystemDNS(strings.Split(value, ",")) dns.FlushCacheWithDefaultResolver() - }() + }) } func (response MethodResponse) send() { data, err := response.JSON() if err != nil { + logError("MethodResponse marshal error: id=%s err=%v", response.ID, err) + releaseObject(response.callback) return } invokeResult(response.callback, string(data)) releaseObject(response.callback) } -func handlePlatformMethodCall(call *MethodCall, response MethodResponse) bool { - switch call.Method { - case updateDnsMethod: - value := "" - if !decodeMethodArguments(call, response, &value) { - return true - } - handleUpdateDns(value) +func init() { + registerMethod(updateDnsMethod, withArguments(func(value *string, response MethodResponse) { + handleUpdateDns(*value) response.success(true) - return true - } - return false + })) } //export invokeMethod func invokeMethod(callback unsafe.Pointer, paramsChar *C.char) { params := takeCString(paramsChar) call := &MethodCall{} - err := json.Unmarshal([]byte(params), call) - if err != nil { - response := MethodResponse{callback: callback} - response.failure("invalid_method_call", err.Error(), nil) + if err := json.Unmarshal([]byte(params), call); err != nil { + newMethodResponse("", callback).failure("invalid_method_call", err.Error(), nil) return } - response := MethodResponse{ - ID: call.ID, - callback: callback, - } - go handleMethodCall(call, response) + go handleMethodCall(call, newMethodResponse(call.ID, callback)) } //export startTUN func startTUN(callback unsafe.Pointer, fd C.int, stackChar, addressChar, dnsChar *C.char) bool { handleStartTun(callback, int(fd), takeCString(stackChar), takeCString(addressChar), takeCString(dnsChar)) - if !isRunning { + if !isRunning.Load() { handleStartListener() } else { handleResetConnections() @@ -214,6 +259,12 @@ func startTUN(callback unsafe.Pointer, fd C.int, stackChar, addressChar, dnsChar func quickSetup(callback unsafe.Pointer, initParamsChar *C.char, setupParamsChar *C.char) { go func() { defer releaseObject(callback) + defer func() { + if r := recover(); r != nil { + logError("panic in quickSetup: %v\n%s", r, stackTrace()) + invokeResult(callback, fmt.Sprintf("internal panic: %v", r)) + } + }() initParamsString := takeCString(initParamsChar) setupParamsString := takeCString(setupParamsChar) initParams := InitParams{} @@ -221,20 +272,21 @@ func quickSetup(callback unsafe.Pointer, initParamsChar *C.char, setupParamsChar invokeResult(callback, "init failed") return } - isRunning = true setupParams := defaultSetupParams() if err := UnmarshalJson([]byte(setupParamsString), setupParams); err != nil { invokeResult(callback, err.Error()) return } - message := handleSetupConfig(setupParams) - invokeResult(callback, message) + isRunning.Store(true) + invokeResult(callback, handleSetupConfig(setupParams)) }() } //export setEventListener func setEventListener(listener unsafe.Pointer) { - if eventListener != nil || listener == nil { + eventListenerLock.Lock() + defer eventListenerLock.Unlock() + if eventListener != nil { releaseObject(eventListener) } eventListener = listener @@ -259,31 +311,19 @@ func marshalResult(value any) string { return string(data) } -func sendMessageBatch(messages []Message) { +func deliverEvent(data []byte) { + eventListenerLock.RLock() + defer eventListenerLock.RUnlock() if eventListener == nil { return } - arguments, err := json.Marshal(messages) - if err != nil { - logError("Message batch marshal error: %v", err) - return - } - call := MethodCall{ - Method: messageMethod, - Arguments: arguments, - } - data, err := json.Marshal(call) - if err != nil { - logError("MethodCall marshal error: method=%s err=%v", call.Method, err) - return - } invokeResult(eventListener, string(data)) } //export stopTun func stopTun() { handleStopTun() - if isRunning { + if isRunning.Load() { handleStopListener() } } diff --git a/core/main.go b/core/main.go index 61ca4e703a..c17d160840 100644 --- a/core/main.go +++ b/core/main.go @@ -1,4 +1,4 @@ -//go:build !cgo +//go:build !(android && cgo) package main @@ -10,7 +10,7 @@ import ( func main() { args := os.Args if len(args) <= 1 { - fmt.Println("Arguments error") + fmt.Fprintln(os.Stderr, "Arguments error") os.Exit(1) } startServer(args[1]) diff --git a/core/main_cgo.go b/core/main_cgo.go index d17e1f0c8d..dff63a2576 100644 --- a/core/main_cgo.go +++ b/core/main_cgo.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build android && cgo package main diff --git a/core/message.go b/core/message.go index cb3dff0a54..84f5a8d9bb 100644 --- a/core/message.go +++ b/core/message.go @@ -1,68 +1,115 @@ package main -import "time" +import ( + "encoding/json" + "time" +) const ( messageBatchInterval = 16 * time.Millisecond messageBatchSize = 32 messageQueueSize = 256 messagePriorityBurst = 8 + messageEvictAttempts = 4 ) var ( + stateMessageQueue = make(chan Message, messageQueueSize) priorityMessageQueue = make(chan Message, messageQueueSize) bulkMessageQueue = make(chan Message, messageQueueSize) ) func init() { - go runMessageBatcher(priorityMessageQueue, bulkMessageQueue, sendMessageBatch) + go runMessageBatcher(stateMessageQueue, priorityMessageQueue, bulkMessageQueue, sendMessageBatch) } -func sendMessage(message Message) { - queue := priorityMessageQueue - if message.Type == LogMessage || message.Type == RequestMessage { - queue = bulkMessageQueue - } - enqueueLatest(queue, message) -} +type messageClass int -func enqueueLatest(queue chan Message, message Message) { - select { - case queue <- message: - return +const ( + stateMessageClass messageClass = iota + priorityMessageClass + bulkMessageClass +) + +func classOfMessage(message Message) messageClass { + switch message.Type { + case LoadedMessage, GeoUpdateMessage: + return stateMessageClass + case LogMessage, RequestMessage: + return bulkMessageClass default: + return priorityMessageClass } +} - // Event delivery must never block the core. Each priority class evicts only - // its own oldest event, so log or request floods cannot displace state. - select { - case <-queue: +func sendMessage(message Message) { + switch classOfMessage(message) { + case stateMessageClass: + enqueueState(stateMessageQueue, message) + case bulkMessageClass: + enqueueLatest(bulkMessageQueue, message) default: + enqueueLatest(priorityMessageQueue, message) } +} + +func enqueueState(queue chan Message, message Message) { select { case queue <- message: default: } } +func enqueueLatest(queue chan Message, message Message) { + for attempt := 0; attempt < messageEvictAttempts; attempt++ { + select { + case queue <- message: + return + default: + } + + select { + case <-queue: + default: + } + } +} + func runMessageBatcher( + stateMessages <-chan Message, priorityMessages <-chan Message, bulkMessages <-chan Message, send func([]Message), ) { - ticker := time.NewTicker(messageBatchInterval) - defer ticker.Stop() + timer := time.NewTimer(messageBatchInterval) + if !timer.Stop() { + <-timer.C + } + defer timer.Stop() + var deadline <-chan time.Time batch := make([]Message, 0, messageBatchSize) + flush := func() { if len(batch) == 0 { return } - current := append([]Message(nil), batch...) - batch = batch[:0] + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + deadline = nil + current := batch + batch = make([]Message, 0, messageBatchSize) send(current) } appendMessage := func(message Message) { + if len(batch) == 0 { + timer.Reset(messageBatchInterval) + deadline = timer.C + } batch = append(batch, message) if len(batch) >= messageBatchSize { flush() @@ -70,7 +117,18 @@ func runMessageBatcher( } priorityBurst := 0 - for priorityMessages != nil || bulkMessages != nil { + for stateMessages != nil || priorityMessages != nil || bulkMessages != nil { + select { + case message, ok := <-stateMessages: + if !ok { + stateMessages = nil + } else { + appendMessage(message) + } + continue + default: + } + // Give bulk events one guaranteed opportunity after a bounded priority // burst, while retaining priority preference under ordinary load. if priorityBurst >= messagePriorityBurst && bulkMessages != nil { @@ -88,7 +146,6 @@ func runMessageBatcher( } } - // Prefer state-bearing events whenever both queues have work. select { case message, ok := <-priorityMessages: if !ok { @@ -102,6 +159,12 @@ func runMessageBatcher( } select { + case message, ok := <-stateMessages: + if !ok { + stateMessages = nil + } else { + appendMessage(message) + } case message, ok := <-priorityMessages: if !ok { priorityMessages = nil @@ -116,9 +179,26 @@ func runMessageBatcher( appendMessage(message) } priorityBurst = 0 - case <-ticker.C: + case <-deadline: flush() } } flush() } + +type messageBatchCall struct { + Method CoreMethod `json:"method"` + Arguments []Message `json:"arguments"` +} + +func sendMessageBatch(messages []Message) { + data, err := json.Marshal(messageBatchCall{ + Method: messageMethod, + Arguments: messages, + }) + if err != nil { + logError("Message batch marshal error: %v", err) + return + } + deliverEvent(data) +} diff --git a/core/message_test.go b/core/message_test.go new file mode 100644 index 0000000000..25f30c4795 --- /dev/null +++ b/core/message_test.go @@ -0,0 +1,279 @@ +package main + +import ( + "sync" + "testing" + "time" +) + +type batchCollector struct { + mu sync.Mutex + batches [][]Message +} + +func (collector *batchCollector) send(messages []Message) { + collector.mu.Lock() + defer collector.mu.Unlock() + collector.batches = append(collector.batches, messages) +} + +func (collector *batchCollector) snapshot() [][]Message { + collector.mu.Lock() + defer collector.mu.Unlock() + return append([][]Message(nil), collector.batches...) +} + +func (collector *batchCollector) flattened() []Message { + var messages []Message + for _, batch := range collector.snapshot() { + messages = append(messages, batch...) + } + return messages +} + +func closedMessages() chan Message { + queue := make(chan Message) + close(queue) + return queue +} + +func runBatcherUntilDrained(t *testing.T, state, priority, bulk chan Message) *batchCollector { + t.Helper() + collector := &batchCollector{} + done := make(chan struct{}) + go func() { + defer close(done) + runMessageBatcher(state, priority, bulk, collector.send) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("runMessageBatcher did not return after every queue closed") + } + return collector +} + +func TestClassOfMessageRoutesEachTier(t *testing.T) { + for messageType, want := range map[MessageType]messageClass{ + LoadedMessage: stateMessageClass, + GeoUpdateMessage: stateMessageClass, + DelayMessage: priorityMessageClass, + LogMessage: bulkMessageClass, + RequestMessage: bulkMessageClass, + } { + if got := classOfMessage(Message{Type: messageType}); got != want { + t.Errorf("classOfMessage(%s) = %d, want %d", messageType, got, want) + } + } +} + +func TestEnqueueStateNeverEvictsAQueuedEvent(t *testing.T) { + queue := make(chan Message, 2) + for i := 0; i < 5; i++ { + enqueueState(queue, Message{Type: GeoUpdateMessage, Data: i}) + } + + if len(queue) != 2 { + t.Fatalf("queue length = %d, want 2", len(queue)) + } + for _, want := range []int{0, 1} { + if got := (<-queue).Data.(int); got != want { + t.Errorf("retained Data = %d, want %d; a state event was evicted", got, want) + } + } +} + +func TestEnqueueStateNeverBlocks(t *testing.T) { + queue := make(chan Message, 1) + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 1000; i++ { + enqueueState(queue, Message{Type: LoadedMessage, Data: i}) + } + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("enqueueState blocked on a full queue") + } +} + +func TestRunMessageBatcherDrainsStateAheadOfADelayBacklog(t *testing.T) { + state := make(chan Message, 1) + priority := make(chan Message, messageBatchSize*2) + for i := 0; i < messageBatchSize*2; i++ { + priority <- Message{Type: DelayMessage, Data: i} + } + state <- Message{Type: GeoUpdateMessage, Data: "finished"} + close(state) + close(priority) + + batches := runBatcherUntilDrained(t, state, priority, closedMessages()).snapshot() + + if len(batches) == 0 { + t.Fatal("the batcher delivered nothing") + } + first := batches[0] + if len(first) == 0 || first[0].Type != GeoUpdateMessage { + t.Fatalf("first delivered message = %+v, want the geo update ahead of the delay backlog", first) + } +} + +func TestEnqueueLatestEvictsOldestOfItsOwnQueue(t *testing.T) { + queue := make(chan Message, 2) + for i := 0; i < 4; i++ { + enqueueLatest(queue, Message{Type: DelayMessage, Data: i}) + } + + if len(queue) != 2 { + t.Fatalf("queue length = %d, want 2", len(queue)) + } + for _, want := range []int{2, 3} { + got := (<-queue).Data.(int) + if got != want { + t.Errorf("retained Data = %d, want %d", got, want) + } + } +} + +func TestEnqueueLatestNeverBlocks(t *testing.T) { + queue := make(chan Message, 1) + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 1000; i++ { + enqueueLatest(queue, Message{Type: LogMessage, Data: i}) + } + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("enqueueLatest blocked on a full queue") + } +} + +func TestRunMessageBatcherPrefersPriorityAndGuaranteesBulkOpportunity(t *testing.T) { + priority := make(chan Message, 64) + bulk := make(chan Message, 64) + for i := 0; i < 20; i++ { + priority <- Message{Type: DelayMessage, Data: i} + } + for i := 0; i < 5; i++ { + bulk <- Message{Type: LogMessage, Data: i} + } + close(priority) + close(bulk) + + got := runBatcherUntilDrained(t, closedMessages(), priority, bulk).flattened() + + want := make([]Message, 0, 25) + appendPriority := func(from, to int) { + for i := from; i < to; i++ { + want = append(want, Message{Type: DelayMessage, Data: i}) + } + } + appendPriority(0, 8) + want = append(want, Message{Type: LogMessage, Data: 0}) + appendPriority(8, 16) + want = append(want, Message{Type: LogMessage, Data: 1}) + appendPriority(16, 20) + want = append(want, + Message{Type: LogMessage, Data: 2}, + Message{Type: LogMessage, Data: 3}, + Message{Type: LogMessage, Data: 4}, + ) + + if len(got) != len(want) { + t.Fatalf("delivered %d messages, want %d", len(got), len(want)) + } + for i := range want { + if got[i].Type != want[i].Type || got[i].Data != want[i].Data { + t.Fatalf( + "message %d = {%s %v}, want {%s %v}", + i, got[i].Type, got[i].Data, want[i].Type, want[i].Data, + ) + } + } +} + +func TestRunMessageBatcherFlushesAtBatchSize(t *testing.T) { + total := messageBatchSize*2 + 6 + priority := make(chan Message, total) + bulk := make(chan Message) + for i := 0; i < total; i++ { + priority <- Message{Type: DelayMessage, Data: i} + } + close(priority) + close(bulk) + + batches := runBatcherUntilDrained(t, closedMessages(), priority, bulk).snapshot() + + wantSizes := []int{messageBatchSize, messageBatchSize, 6} + if len(batches) != len(wantSizes) { + t.Fatalf("got %d batches, want %d", len(batches), len(wantSizes)) + } + for i, wantSize := range wantSizes { + if len(batches[i]) != wantSize { + t.Errorf("batch %d size = %d, want %d", i, len(batches[i]), wantSize) + } + } +} + +func TestRunMessageBatcherFlushesOnInterval(t *testing.T) { + priority := make(chan Message, 4) + bulk := make(chan Message, 4) + collector := &batchCollector{} + done := make(chan struct{}) + go func() { + defer close(done) + runMessageBatcher(closedMessages(), priority, bulk, collector.send) + }() + + for i := 0; i < 3; i++ { + priority <- Message{Type: DelayMessage, Data: i} + } + + deadline := time.After(2 * time.Second) + for { + if len(collector.flattened()) == 3 { + break + } + select { + case <-deadline: + t.Fatal("batcher did not flush a partial batch on the interval tick") + case <-time.After(5 * time.Millisecond): + } + } + + close(priority) + close(bulk) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("runMessageBatcher did not return after every queue closed") + } +} + +func TestRunMessageBatcherDrainsBulkAfterPriorityCloses(t *testing.T) { + priority := make(chan Message) + bulk := make(chan Message, 4) + for i := 0; i < 4; i++ { + bulk <- Message{Type: RequestMessage, Data: i} + } + close(priority) + close(bulk) + + got := runBatcherUntilDrained(t, closedMessages(), priority, bulk).flattened() + + if len(got) != 4 { + t.Fatalf("delivered %d bulk messages, want 4", len(got)) + } + for i, message := range got { + if message.Type != RequestMessage || message.Data != i { + t.Errorf("message %d = {%s %v}, want {request %d}", i, message.Type, message.Data, i) + } + } +} diff --git a/core/method.go b/core/method.go index 62c4c3abaf..4c5353a566 100644 --- a/core/method.go +++ b/core/method.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "runtime" + "sync/atomic" "unsafe" ) @@ -43,19 +44,41 @@ type MethodResponse struct { Result any `json:"result"` Error *MethodError `json:"error,omitempty"` callback unsafe.Pointer + sent *atomic.Bool +} + +func newMethodResponse(id string, callback unsafe.Pointer) MethodResponse { + return MethodResponse{ + ID: id, + callback: callback, + sent: &atomic.Bool{}, + } } func (response MethodResponse) JSON() ([]byte, error) { return json.Marshal(response) } +func (response MethodResponse) claim() bool { + if response.sent == nil { + return true + } + return response.sent.CompareAndSwap(false, true) +} + func (response MethodResponse) success(result any) { + if !response.claim() { + return + } response.Result = result response.Error = nil response.send() } func (response MethodResponse) failure(code, message string, details any) { + if !response.claim() { + return + } response.Result = nil response.Error = &MethodError{ Code: code, @@ -73,204 +96,210 @@ func (response MethodResponse) notImplemented(method CoreMethod) { ) } -func handleMethodCall(call *MethodCall, response MethodResponse) { - // The crash method is a developer-only fatal-path test. It must bypass the - // recovery below so the core process terminates; on Android this also - // terminates the in-process application. - if call.Method == crashMethod { - handleCrash() - return - } - defer func() { - if r := recover(); r != nil { - buf := make([]byte, 4096) - n := runtime.Stack(buf, false) - logError("panic in handleMethodCall(%s): %v\n%s", call.Method, r, buf[:n]) - response.failure("internal_error", fmt.Sprintf("internal panic: %v", r), nil) - } +func stackTrace() []byte { + buf := make([]byte, 4096) + return buf[:runtime.Stack(buf, false)] +} + +func safeGo(response MethodResponse, run func()) { + go func() { + defer func() { + if r := recover(); r != nil { + logError("panic in async handler: %v\n%s", r, stackTrace()) + response.failure("internal_error", fmt.Sprintf("internal panic: %v", r), nil) + } + }() + run() }() - switch call.Method { - case initClashMethod: - params := InitParams{} +} + +func safeGoDetached(name string, run func()) { + go func() { + defer func() { + if r := recover(); r != nil { + logError("panic in %s: %v\n%s", name, r, stackTrace()) + } + }() + run() + }() +} + +type methodHandler func(call *MethodCall, response MethodResponse) + +func withArguments[T any](handle func(params *T, response MethodResponse)) methodHandler { + return func(call *MethodCall, response MethodResponse) { + var params T if !decodeMethodArguments(call, response, ¶ms) { return } - response.success(handleInitClash(¶ms)) - return - case getIsInitMethod: + handle(¶ms, response) + } +} + +func withDefaults[T any]( + defaults func() *T, + handle func(params *T, response MethodResponse), +) methodHandler { + return func(call *MethodCall, response MethodResponse) { + params := defaults() + if !decodeMethodArguments(call, response, params) { + return + } + handle(params, response) + } +} + +func withoutArguments(handle func(response MethodResponse)) methodHandler { + return func(_ *MethodCall, response MethodResponse) { + handle(response) + } +} + +var methodHandlers = map[CoreMethod]methodHandler{ + initClashMethod: withArguments(func(params *InitParams, response MethodResponse) { + response.success(handleInitClash(params)) + }), + getIsInitMethod: withoutArguments(func(response MethodResponse) { response.success(handleGetIsInit()) - return - case forceGcMethod: + }), + forceGcMethod: withoutArguments(func(response MethodResponse) { handleForceGC() response.success(true) - return - case shutdownMethod: + }), + shutdownMethod: withoutArguments(func(response MethodResponse) { response.success(handleShutdown()) - return - case validateConfigMethod: - path := "" - if !decodeMethodArguments(call, response, &path) { - return - } - response.success(handleValidateConfig(path)) - return - case updateConfigMethod: - params := UpdateParams{} - if !decodeMethodArguments(call, response, ¶ms) { - return - } - response.success(handleUpdateConfig(¶ms)) - return - case setupConfigMethod: - params := defaultSetupParams() - if !decodeMethodArguments(call, response, params) { - return - } + }), + validateConfigMethod: withArguments(func(path *string, response MethodResponse) { + response.success(handleValidateConfig(*path)) + }), + updateConfigMethod: withArguments(func(params *UpdateParams, response MethodResponse) { + response.success(handleUpdateConfig(params)) + }), + setupConfigMethod: withDefaults(defaultSetupParams, func(params *SetupParams, response MethodResponse) { response.success(handleSetupConfig(params)) - return - case getProxiesMethod: - response.success(handleGetProxies()) - return - case changeProxyMethod: - params := ChangeProxyParams{} - if !decodeMethodArguments(call, response, ¶ms) { + }), + getConfigMethod: withArguments(func(path *string, response MethodResponse) { + rawConfig, err := handleGetConfig(*path) + if err != nil { + response.failure("core_error", err.Error(), nil) return } - handleChangeProxy(¶ms, func(value string) { - response.success(value) + response.success(rawConfig) + }), + getProxiesMethod: withoutArguments(func(response MethodResponse) { + response.success(handleGetProxies()) + }), + changeProxyMethod: withArguments(func(params *ChangeProxyParams, response MethodResponse) { + safeGo(response, func() { + response.success(handleChangeProxy(params)) }) - return - case getTrafficMethod: - onlyStatisticsProxy := false - if !decodeMethodArguments(call, response, &onlyStatisticsProxy) { - return - } - response.success(handleGetTraffic(onlyStatisticsProxy)) - return - case getTotalTrafficMethod: - onlyStatisticsProxy := false - if !decodeMethodArguments(call, response, &onlyStatisticsProxy) { - return - } - response.success(handleGetTotalTraffic(onlyStatisticsProxy)) - return - case resetTrafficMethod: + }), + getTrafficMethod: withArguments(func(onlyStatisticsProxy *bool, response MethodResponse) { + response.success(handleGetTraffic(*onlyStatisticsProxy)) + }), + getTotalTrafficMethod: withArguments(func(onlyStatisticsProxy *bool, response MethodResponse) { + response.success(handleGetTotalTraffic(*onlyStatisticsProxy)) + }), + resetTrafficMethod: withoutArguments(func(response MethodResponse) { handleResetTraffic() response.success(true) - return - case asyncTestDelayMethod: - params := TestDelayParams{} - if !decodeMethodArguments(call, response, ¶ms) { - return - } - handleAsyncTestDelay(¶ms, func(value *Delay) { - response.success(value) + }), + asyncTestDelayMethod: withArguments(func(params *TestDelayParams, response MethodResponse) { + safeGo(response, func() { + response.success(handleTestDelay(params)) }) - return - case getConnectionsMethod: + }), + getConnectionsMethod: withoutArguments(func(response MethodResponse) { response.success(handleGetConnections()) - return - case closeConnectionsMethod: + }), + closeConnectionsMethod: withoutArguments(func(response MethodResponse) { response.success(handleCloseConnections()) - return - case resetConnectionsMethod: + }), + resetConnectionsMethod: withoutArguments(func(response MethodResponse) { response.success(handleResetConnections()) - return - case getConfigMethod: - path := "" - if !decodeMethodArguments(call, response, &path) { - return - } - config, err := handleGetConfig(path) - if err != nil { - response.failure("core_error", err.Error(), nil) - return - } - response.success(config) - return - case closeConnectionMethod: - id := "" - if !decodeMethodArguments(call, response, &id) { - return - } - response.success(handleCloseConnection(id)) - return - case getExternalProvidersMethod: + }), + closeConnectionMethod: withArguments(func(id *string, response MethodResponse) { + response.success(handleCloseConnection(*id)) + }), + getExternalProvidersMethod: withoutArguments(func(response MethodResponse) { response.success(handleGetExternalProviders()) - return - case getExternalProviderMethod: - externalProviderName := "" - if !decodeMethodArguments(call, response, &externalProviderName) { - return - } - response.success(handleGetExternalProvider(externalProviderName)) - return - case updateGeoDataMethod: - geoType := "" - if !decodeMethodArguments(call, response, &geoType) { - return - } - handleUpdateGeoData(geoType) - response.success("") - return - case updateExternalProviderMethod: - providerName := "" - if !decodeMethodArguments(call, response, &providerName) { - return - } - handleUpdateExternalProvider(providerName, func(value string) { - response.success(value) + }), + getExternalProviderMethod: withArguments(func(name *string, response MethodResponse) { + response.success(handleGetExternalProvider(*name)) + }), + updateExternalProviderMethod: withArguments(func(name *string, response MethodResponse) { + safeGo(response, func() { + if err := handleUpdateExternalProvider(*name); err != nil { + response.failure(err.Code, err.Message, err.Details) + return + } + response.success("") }) - return - case sideLoadExternalProviderMethod: - params := map[string]string{} - if !decodeMethodArguments(call, response, ¶ms) { - return - } - providerName := params["providerName"] - data := params["data"] - handleSideLoadExternalProvider(providerName, []byte(data), func(value string) { - response.success(value) + }), + sideLoadExternalProviderMethod: withArguments(func(params *SideLoadParams, response MethodResponse) { + safeGo(response, func() { + if err := handleSideLoadExternalProvider(params.ProviderName, []byte(params.Data)); err != nil { + response.failure(err.Code, err.Message, err.Details) + return + } + response.success("") }) - return - case startLogMethod: + }), + updateGeoDataMethod: withArguments(func(geoType *string, response MethodResponse) { + response.success(handleUpdateGeoData(*geoType)) + }), + startLogMethod: withoutArguments(func(response MethodResponse) { handleStartLog() response.success(true) - return - case stopLogMethod: + }), + stopLogMethod: withoutArguments(func(response MethodResponse) { handleStopLog() response.success(true) - return - case startListenerMethod: + }), + startListenerMethod: withoutArguments(func(response MethodResponse) { response.success(handleStartListener()) - return - case stopListenerMethod: + }), + stopListenerMethod: withoutArguments(func(response MethodResponse) { response.success(handleStopListener()) - return - case getCountryCodeMethod: - ip := "" - if !decodeMethodArguments(call, response, &ip) { - return - } - handleGetCountryCode(ip, func(value string) { - response.success(value) + }), + getMemoryMethod: withoutArguments(func(response MethodResponse) { + safeGo(response, func() { + response.success(handleGetMemory()) }) - return - case getMemoryMethod: - handleGetMemory(func(value uint64) { - response.success(value) + }), + clearEffectMethod: withArguments(func(profileId *int64, response MethodResponse) { + safeGo(response, func() { + response.success(handleClearEffect(*profileId)) }) + }), +} + +func registerMethod(method CoreMethod, handler methodHandler) { + if _, exists := methodHandlers[method]; exists { + panic(fmt.Sprintf("duplicate handler for method %s", method)) + } + methodHandlers[method] = handler +} + +func handleMethodCall(call *MethodCall, response MethodResponse) { + // The crash method is a developer-only fatal-path test. It must bypass the + // recovery below so the core process terminates; on Android this also + // terminates the in-process application. + if call.Method == crashMethod { + handleCrash() return - case clearEffectMethod: - var profileId int64 - if !decodeMethodArguments(call, response, &profileId) { - return + } + defer func() { + if r := recover(); r != nil { + logError("panic in handleMethodCall(%s): %v\n%s", call.Method, r, stackTrace()) + response.failure("internal_error", fmt.Sprintf("internal panic: %v", r), nil) } - handleClearEffect(profileId, response) + }() + + handler, exists := methodHandlers[call.Method] + if !exists { + response.notImplemented(call.Method) return - default: - if !handlePlatformMethodCall(call, response) { - response.notImplemented(call.Method) - } } + handler(call, response) } diff --git a/core/ownership_other.go b/core/ownership_other.go new file mode 100644 index 0000000000..c1a49fc1e7 --- /dev/null +++ b/core/ownership_other.go @@ -0,0 +1,7 @@ +//go:build !(darwin || linux) || android + +package main + +func initOwnership(homeDir string) {} + +func scheduleReclaimOwnership() {} diff --git a/core/ownership_test.go b/core/ownership_test.go new file mode 100644 index 0000000000..b28621742d --- /dev/null +++ b/core/ownership_test.go @@ -0,0 +1,246 @@ +//go:build (darwin || linux) && !android + +package main + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestCanReclaimRequiresADirectoryOwnedByTheCaller(t *testing.T) { + homeDir := t.TempDir() + uid := os.Getuid() + + if !canReclaim(homeDir, uid) { + t.Fatal("a directory owned by the caller must be reclaimable") + } + if canReclaim(homeDir, uid+1) { + t.Error("a directory owned by somebody else must be refused") + } + if canReclaim("", uid) { + t.Error("an empty home directory must be refused") + } + if canReclaim(filepath.Join(homeDir, "missing"), uid) { + t.Error("a missing path must be refused") + } + + file := filepath.Join(homeDir, "config.yaml") + if err := os.WriteFile(file, []byte("mixed-port: 7890\n"), 0o600); err != nil { + t.Fatalf("write error: %v", err) + } + if canReclaim(file, uid) { + t.Error("a regular file must be refused") + } + + link := filepath.Join(homeDir, "link") + if err := os.Symlink(homeDir, link); err != nil { + t.Fatalf("symlink error: %v", err) + } + if canReclaim(link, uid) { + t.Error("a symlink to a directory must be refused") + } +} + +func TestShouldReclaimEntrySkipsOwnedLinkedAndSymlinkedEntries(t *testing.T) { + homeDir := t.TempDir() + uid := os.Getuid() + foreign := uid + 1 + + file := filepath.Join(homeDir, "provider") + if err := os.WriteFile(file, []byte("proxies: []\n"), 0o600); err != nil { + t.Fatalf("write error: %v", err) + } + info, err := os.Lstat(file) + if err != nil { + t.Fatalf("lstat error: %v", err) + } + if !shouldReclaimEntry(info, foreign) { + t.Error("a file owned by another user must be reclaimed") + } + if shouldReclaimEntry(info, uid) { + t.Error("a file already owned by the caller must be skipped") + } + + hardLink := filepath.Join(homeDir, "hardlink") + if err := os.Link(file, hardLink); err != nil { + t.Fatalf("link error: %v", err) + } + linkInfo, err := os.Lstat(hardLink) + if err != nil { + t.Fatalf("lstat error: %v", err) + } + if shouldReclaimEntry(linkInfo, foreign) { + t.Error("a hard link must be skipped so it cannot smuggle in a foreign inode") + } + + symLink := filepath.Join(homeDir, "symlink") + if err := os.Symlink(file, symLink); err != nil { + t.Fatalf("symlink error: %v", err) + } + symInfo, err := os.Lstat(symLink) + if err != nil { + t.Fatalf("lstat error: %v", err) + } + if shouldReclaimEntry(symInfo, foreign) { + t.Error("a symlink must be skipped") + } + + dirInfo, err := os.Lstat(homeDir) + if err != nil { + t.Fatalf("lstat error: %v", err) + } + if !shouldReclaimEntry(dirInfo, foreign) { + t.Error("a directory must be reclaimed even though its link count exceeds one") + } +} + +func TestCollectReclaimTargetsWalksWithoutFollowingSymlinks(t *testing.T) { + homeDir := t.TempDir() + uid := os.Getuid() + + providers := filepath.Join(homeDir, "profiles", "providers", "1", "proxies") + if err := os.MkdirAll(providers, 0o755); err != nil { + t.Fatalf("mkdir error: %v", err) + } + if err := os.WriteFile(filepath.Join(providers, "abc"), []byte("proxies: []\n"), 0o600); err != nil { + t.Fatalf("write error: %v", err) + } + + if targets := collectReclaimTargets(homeDir, uid); len(targets) != 0 { + t.Errorf("targets = %v, want nothing when the caller already owns the tree", targets) + } + if targets := collectReclaimTargets(homeDir, uid+1); targets != nil { + t.Errorf("targets = %v, want nil when the home directory belongs to somebody else", targets) + } + + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("secret"), 0o600); err != nil { + t.Fatalf("write error: %v", err) + } + if err := os.Symlink(outside, filepath.Join(homeDir, "escape")); err != nil { + t.Fatalf("symlink error: %v", err) + } + for _, target := range collectReclaimTargets(homeDir, uid) { + if target.path == filepath.Join(outside, "secret") { + t.Fatal("the walk must not follow a symlink out of the home directory") + } + } +} + +func reclaimTargetFor(t *testing.T, path string) reclaimTarget { + t.Helper() + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("lstat error: %v", err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Fatalf("stat type = %T, want *syscall.Stat_t", info.Sys()) + } + return reclaimTarget{path: path, dev: uint64(stat.Dev), ino: uint64(stat.Ino)} +} + +func TestReclaimEntryVerifiesTheInodeItChowns(t *testing.T) { + homeDir := t.TempDir() + uid := os.Getuid() + gid := os.Getgid() + foreign := uid + 1 + + file := filepath.Join(homeDir, "provider") + if err := os.WriteFile(file, []byte("proxies: []\n"), 0o600); err != nil { + t.Fatalf("write error: %v", err) + } + + if ok, err := reclaimEntry(reclaimTargetFor(t, file), uid, gid); ok || err != nil { + t.Errorf("reclaimEntry = (%v, %v), want no work for an entry the caller owns", ok, err) + } + + hardLink := filepath.Join(homeDir, "hardlink") + if err := os.Link(file, hardLink); err != nil { + t.Fatalf("link error: %v", err) + } + if ok, err := reclaimEntry(reclaimTargetFor(t, hardLink), foreign, gid); ok || err != nil { + t.Errorf("reclaimEntry = (%v, %v), want a hard link left alone", ok, err) + } + + symLink := filepath.Join(homeDir, "symlink") + if err := os.Symlink(file, symLink); err != nil { + t.Fatalf("symlink error: %v", err) + } + if _, err := reclaimEntry(reclaimTargetFor(t, symLink), foreign, gid); !errors.Is(err, syscall.ELOOP) { + t.Errorf("reclaimEntry error = %v, want ELOOP so a swapped final component cannot redirect the chown", err) + } + + fifo := filepath.Join(homeDir, "fifo") + if err := syscall.Mkfifo(fifo, 0o600); err != nil { + t.Fatalf("mkfifo error: %v", err) + } + if ok, err := reclaimEntry(reclaimTargetFor(t, fifo), foreign, gid); ok || err != nil { + t.Errorf("reclaimEntry = (%v, %v), want a fifo skipped without blocking", ok, err) + } + + missing := reclaimTarget{path: filepath.Join(homeDir, "missing")} + if _, err := reclaimEntry(missing, foreign, gid); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("reclaimEntry error = %v, want a missing entry reported as absent", err) + } + + plain := filepath.Join(homeDir, "plain") + if err := os.WriteFile(plain, []byte("proxies: []\n"), 0o600); err != nil { + t.Fatalf("write error: %v", err) + } + if _, err := reclaimEntry(reclaimTargetFor(t, plain), foreign, gid); err == nil { + t.Error("a foreign-owned regular file must reach fchown, which only root may complete") + } +} + +func TestReclaimEntryRefusesAPathSwappedThroughAnIntermediateComponent(t *testing.T) { + homeDir := t.TempDir() + outside := t.TempDir() + gid := os.Getgid() + foreign := os.Getuid() + 1 + + profiles := filepath.Join(homeDir, "profiles") + if err := os.Mkdir(profiles, 0o755); err != nil { + t.Fatalf("mkdir error: %v", err) + } + entry := filepath.Join(profiles, "provider") + if err := os.WriteFile(entry, []byte("proxies: []\n"), 0o600); err != nil { + t.Fatalf("write error: %v", err) + } + target := reclaimTargetFor(t, entry) + + if err := os.WriteFile(filepath.Join(outside, "provider"), []byte("secret"), 0o600); err != nil { + t.Fatalf("write error: %v", err) + } + if err := os.Rename(profiles, filepath.Join(homeDir, "moved")); err != nil { + t.Fatalf("rename error: %v", err) + } + if err := os.Symlink(outside, profiles); err != nil { + t.Fatalf("symlink error: %v", err) + } + + if ok, err := reclaimEntry(target, foreign, gid); ok || err != nil { + t.Errorf("reclaimEntry = (%v, %v), want the chown refused once an intermediate component was swapped", ok, err) + } +} + +func TestScheduleReclaimOwnershipOnlyUsesTheHomeDirInitPassed(t *testing.T) { + t.Cleanup(func() { reclaimHomeDir.Store("") }) + reclaimHomeDir.Store("") + + scheduleReclaimOwnership() + + if homeDir, _ := reclaimHomeDir.Load().(string); homeDir != "" { + t.Errorf("home dir = %q, want the sweep to stay unarmed until init records one", homeDir) + } + + initOwnership(t.TempDir()) + + if homeDir, _ := reclaimHomeDir.Load().(string); homeDir == "" { + t.Error("init must record the home directory the app handed the core") + } +} diff --git a/core/ownership_unix.go b/core/ownership_unix.go new file mode 100644 index 0000000000..52fe1f645d --- /dev/null +++ b/core/ownership_unix.go @@ -0,0 +1,177 @@ +//go:build (darwin || linux) && !android + +package main + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "sync/atomic" + "syscall" + "time" + + "github.com/metacubex/mihomo/log" +) + +const reclaimDebounce = 2 * time.Second + +var ( + realUid = os.Getuid() + realGid = os.Getgid() + + reclaimPending atomic.Bool + reclaimHomeDir atomic.Value +) + +func isElevated() bool { + return os.Geteuid() == 0 && realUid != 0 +} + +func canReclaim(homeDir string, uid int) bool { + if homeDir == "" { + return false + } + info, err := os.Lstat(homeDir) + if err != nil || !info.IsDir() { + return false + } + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && int(stat.Uid) == uid +} + +func shouldReclaimEntry(info fs.FileInfo, uid int) bool { + if info.Mode()&fs.ModeSymlink != 0 { + return false + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false + } + if int(stat.Uid) == uid { + return false + } + return info.IsDir() || stat.Nlink <= 1 +} + +type reclaimTarget struct { + path string + dev uint64 + ino uint64 +} + +func collectReclaimTargets(homeDir string, uid int) []reclaimTarget { + if !canReclaim(homeDir, uid) { + return nil + } + var targets []reclaimTarget + _ = filepath.WalkDir(homeDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + info, infoErr := d.Info() + if infoErr != nil { + return nil + } + if !shouldReclaimEntry(info, uid) { + return nil + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return nil + } + targets = append(targets, reclaimTarget{ + path: path, + dev: uint64(stat.Dev), + ino: uint64(stat.Ino), + }) + return nil + }) + return targets +} + +func isReclaimableStat(stat *syscall.Stat_t, uid int) bool { + switch stat.Mode & syscall.S_IFMT { + case syscall.S_IFDIR: + case syscall.S_IFREG: + if stat.Nlink > 1 { + return false + } + default: + return false + } + return int(stat.Uid) != uid +} + +func reclaimEntry(target reclaimTarget, uid int, gid int) (bool, error) { + fd, err := syscall.Open( + target.path, + syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK|syscall.O_CLOEXEC, + 0, + ) + if err != nil { + return false, err + } + defer syscall.Close(fd) + var stat syscall.Stat_t + if err := syscall.Fstat(fd, &stat); err != nil { + return false, err + } + if uint64(stat.Dev) != target.dev || uint64(stat.Ino) != target.ino { + return false, nil + } + if !isReclaimableStat(&stat, uid) { + return false, nil + } + if err := syscall.Fchown(fd, uid, gid); err != nil { + return false, err + } + return true, nil +} + +func reclaimOwnership(homeDir string) { + if !isElevated() { + return + } + targets := collectReclaimTargets(homeDir, realUid) + if len(targets) == 0 { + return + } + reclaimed := 0 + for _, target := range targets { + ok, err := reclaimEntry(target, realUid, realGid) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + log.Warnln("[APP] reclaim %s: %v", target.path, err) + } + continue + } + if ok { + reclaimed++ + } + } + log.Infoln("[APP] reclaimed %d of %d elevated entries under %s", reclaimed, len(targets), homeDir) +} + +func initOwnership(homeDir string) { + reclaimHomeDir.Store(homeDir) + reclaimOwnership(homeDir) +} + +func scheduleReclaimOwnership() { + if !isElevated() { + return + } + homeDir, _ := reclaimHomeDir.Load().(string) + if homeDir == "" { + return + } + if !reclaimPending.CompareAndSwap(false, true) { + return + } + go func() { + time.Sleep(reclaimDebounce) + reclaimPending.Store(false) + reclaimOwnership(homeDir) + }() +} diff --git a/core/platform/limit.go b/core/platform/limit.go index 572357e22d..c712620f74 100644 --- a/core/platform/limit.go +++ b/core/platform/limit.go @@ -2,15 +2,33 @@ package platform -import "syscall" +import ( + "sync/atomic" + "syscall" + "time" -var nullFd int + "github.com/metacubex/mihomo/log" +) + +const fdPressureWindow = 10 * time.Millisecond + +var nullFd = -1 var maxFdCount int +var ( + lastProbeAt atomic.Int64 + lastProbeBlocked atomic.Bool +) + func init() { + // This runs while the shared library is being loaded, so a panic here takes + // the application down before it has a chance to report anything. The probe + // is a safety valve against fd exhaustion rather than something correctness + // depends on, so a failure to arm it degrades to never blocking. fd, err := syscall.Open("/dev/null", syscall.O_WRONLY, 0644) if err != nil { - panic(err.Error()) + log.Errorln("[APP] fd pressure probe disabled: %v", err) + return } nullFd = fd @@ -27,6 +45,24 @@ func init() { } func ShouldBlockConnection() bool { + if nullFd < 0 { + return false + } + + now := time.Now().UnixNano() + if !lastProbeBlocked.Load() { + if last := lastProbeAt.Load(); last != 0 && now-last < int64(fdPressureWindow) { + return false + } + } + + blocked := probeFdPressure() + lastProbeBlocked.Store(blocked) + lastProbeAt.Store(now) + return blocked +} + +func probeFdPressure() bool { fd, err := syscall.Dup(nullFd) if err != nil { return true @@ -34,9 +70,5 @@ func ShouldBlockConnection() bool { _ = syscall.Close(fd) - if fd > maxFdCount { - return true - } - - return false + return fd > maxFdCount } diff --git a/core/platform/procfs.go b/core/platform/procfs.go index a6d604be00..1fbbc937bb 100644 --- a/core/platform/procfs.go +++ b/core/platform/procfs.go @@ -4,9 +4,9 @@ package platform import ( "bufio" + "bytes" "encoding/binary" "encoding/hex" - "fmt" "net" "os" "strconv" @@ -63,6 +63,40 @@ func QuerySocketUidFromProcFs(source, _ net.Addr) int { return uid } +func localAddressColumn(sIP net.IP, sPort int) []byte { + ip := nativeEndianIP(sIP) + column := make([]byte, 0, hex.EncodedLen(len(ip))+1+4) + + encoded := make([]byte, hex.EncodedLen(len(ip))) + hex.Encode(encoded, ip) + column = append(column, encoded...) + + column = append(column, ':') + + var port [2]byte + binary.BigEndian.PutUint16(port[:], uint16(sPort)) + var encodedPort [4]byte + hex.Encode(encodedPort[:], port[:]) + return append(column, encodedPort[:]...) +} + +func column(row []byte, index int) []byte { + for i := 0; ; i++ { + row = bytes.TrimLeft(row, " \t") + if len(row) == 0 { + return nil + } + end := bytes.IndexAny(row, " \t") + if end < 0 { + end = len(row) + } + if i == index { + return row[:end] + } + row = row[end:] + } +} + func doQuery(path string, sIP net.IP, sPort int) int { file, err := os.Open(path) if err != nil { @@ -73,35 +107,30 @@ func doQuery(path string, sIP net.IP, sPort int) int { _ = file.Close() }(file) - reader := bufio.NewReader(file) + local := localAddressColumn(sIP, sPort) - var bytes [2]byte + scanner := bufio.NewScanner(file) + for scanner.Scan() { + row := scanner.Bytes() - binary.BigEndian.PutUint16(bytes[:], uint16(sPort)) - - local := fmt.Sprintf("%s:%s", hex.EncodeToString(nativeEndianIP(sIP)), hex.EncodeToString(bytes[:])) + if !bytes.EqualFold(local, column(row, netIndexOfLocal)) { + continue + } - for { - row, _, err := reader.ReadLine() - if err != nil { + uidColumn := column(row, netIndexOfUid) + if uidColumn == nil { return -1 } - fields := strings.Fields(string(row)) - - if len(fields) <= netIndexOfLocal || len(fields) <= netIndexOfUid { - continue + uid, err := strconv.Atoi(string(uidColumn)) + if err != nil { + return -1 } - if strings.EqualFold(local, fields[netIndexOfLocal]) { - uid, err := strconv.Atoi(fields[netIndexOfUid]) - if err != nil { - return -1 - } - - return uid - } + return uid } + + return -1 } func nativeEndianIP(ip net.IP) []byte { diff --git a/core/server.go b/core/server.go index 28b8cb6135..26786b2e15 100644 --- a/core/server.go +++ b/core/server.go @@ -1,21 +1,47 @@ -//go:build !cgo +//go:build !(android && cgo) package main import ( "encoding/binary" "encoding/json" + "errors" "fmt" "io" + "os" "sync" + "sync/atomic" + "time" ) +type ipcConn interface { + io.ReadWriteCloser + SetWriteDeadline(t time.Time) error +} + var ( - conn io.ReadWriteCloser - connMu sync.Mutex + conn ipcConn + connMu sync.Mutex + writeMu sync.Mutex +) + +const ( + maxIPCFrameSize = 64 * 1024 * 1024 + ipcWriteTimeout = 10 * time.Second + ipcPartialFrameRetries = 6 ) -const maxIPCFrameSize = 64 * 1024 * 1024 +var deliveryFailureReported atomic.Bool + +// logDeliveryError must not reach the mihomo logger: a log event is published +// to the log subscriber, batched, and handed back to send, so reporting a send +// failure through it feeds the failure straight back into itself. +func logDeliveryError(format string, args ...any) { + if deliveryFailureReported.Swap(true) { + return + } + fmt.Fprintf(os.Stderr, "[ERROR] "+format+"\n", args...) +} func (response MethodResponse) send() { data, err := response.JSON() @@ -26,48 +52,70 @@ func (response MethodResponse) send() { send(data) } -func sendMessageBatch(messages []Message) { - arguments, err := json.Marshal(messages) - if err != nil { - logError("Message batch marshal error: %v", err) - return - } - call := MethodCall{ - Method: messageMethod, - Arguments: arguments, - } - data, err := json.Marshal(call) - if err != nil { - logError("MethodCall marshal error: method=%s err=%v", call.Method, err) - return - } +func deliverEvent(data []byte) { send(data) } -func writeFrame(w io.Writer, data []byte) error { +func writeFrame(w io.Writer, data []byte) (int, error) { if len(data) > maxIPCFrameSize { - return fmt.Errorf("IPC frame exceeds %d bytes", maxIPCFrameSize) + return 0, fmt.Errorf("IPC frame exceeds %d bytes", maxIPCFrameSize) } lenBuf := [4]byte{} binary.LittleEndian.PutUint32(lenBuf[:], uint32(len(data))) - if err := writeAll(w, lenBuf[:]); err != nil { - return err + written, err := writeAll(w, lenBuf[:]) + if err != nil { + return written, err + } + n, err := writeAll(w, data) + return written + n, err +} + +type resumingWriter struct { + conn ipcConn + written int + stalls int +} + +func (writer *resumingWriter) Write(data []byte) (int, error) { + accepted := 0 + for { + n, err := writer.conn.Write(data[accepted:]) + accepted += n + writer.written += n + if err == nil { + return accepted, nil + } + if accepted >= len(data) || !writer.resume(err) { + return accepted, err + } + } +} + +func (writer *resumingWriter) resume(err error) bool { + if writer.written == 0 || writer.stalls >= ipcPartialFrameRetries { + return false + } + if !errors.Is(err, os.ErrDeadlineExceeded) { + return false } - return writeAll(w, data) + writer.stalls++ + return writer.conn.SetWriteDeadline(time.Now().Add(ipcWriteTimeout)) == nil } -func writeAll(w io.Writer, data []byte) error { +func writeAll(w io.Writer, data []byte) (int, error) { + written := 0 for len(data) > 0 { n, err := w.Write(data) + written += n if err != nil { - return err + return written, err } if n == 0 { - return io.ErrShortWrite + return written, io.ErrShortWrite } data = data[n:] } - return nil + return written, nil } func readFrame(r io.Reader) ([]byte, error) { @@ -87,53 +135,73 @@ func readFrame(r io.Reader) ([]byte, error) { } func send(data []byte) { - if conn == nil { - logError("send conn nil") + writeMu.Lock() + defer writeMu.Unlock() + + connMu.Lock() + c := conn + connMu.Unlock() + + if c == nil { + logDeliveryError("send conn nil") + return + } + if err := c.SetWriteDeadline(time.Now().Add(ipcWriteTimeout)); err != nil { + logDeliveryError("server write deadline error: %v", err) + } + written, err := writeFrame(&resumingWriter{conn: c}, data) + if err == nil { + deliveryFailureReported.Store(false) return } + if written == 0 { + logDeliveryError("server write error, dropped one frame: %v", err) + return + } + logDeliveryError("server write error after %d bytes: %v", written, err) connMu.Lock() - defer connMu.Unlock() - if err := writeFrame(conn, data); err != nil { - logError("server write error: %v", err) + if conn == c { + conn = nil } + connMu.Unlock() + _ = c.Close() } func startServer(arg string) { - var err error - conn, err = dial(arg) + dialed, err := dial(arg) if err != nil { panic(err.Error()) } + defer func() { + connMu.Lock() + c := conn + conn = nil + connMu.Unlock() + if c != nil { + _ = c.Close() + } + }() - defer func(conn io.Closer) { - _ = conn.Close() - }(conn) + connMu.Lock() + conn = dialed + deliveryFailureReported.Store(false) + connMu.Unlock() for { - data, err := readFrame(conn) + data, err := readFrame(dialed) if err != nil { if err != io.EOF { logError("server read error: %v", err) } return } - call := &MethodCall{} - - err = json.Unmarshal(data, call) - if err != nil { + call := &MethodCall{} + if err := json.Unmarshal(data, call); err != nil { logError("server unmarshal error: %v (data: %q)", err, data) continue } - response := MethodResponse{ - ID: call.ID, - } - - go handleMethodCall(call, response) + go handleMethodCall(call, newMethodResponse(call.ID, nil)) } } - -func handlePlatformMethodCall(call *MethodCall, response MethodResponse) bool { - return false -} diff --git a/core/tun/tun.go b/core/tun/tun.go index 5d5d1d0907..6498617e18 100644 --- a/core/tun/tun.go +++ b/core/tun/tun.go @@ -28,7 +28,7 @@ func Start(fd int, stack string, address, dns string) *sing_tun.Listener { } prefix, err := netip.ParsePrefix(a) if err != nil { - log.Errorln("TUN:", err) + log.Errorln("TUN: %v", err) return nil } if prefix.Addr().Is4() { @@ -63,7 +63,7 @@ func Start(fd int, stack string, address, dns string) *sing_tun.Listener { listener, err := sing_tun.New(options, tunnel.Tunnel) if err != nil { - log.Errorln("TUN:", err) + log.Errorln("TUN: %v", err) return nil } diff --git a/lib/application.dart b/lib/application.dart index 2bd4829327..fb37633c4c 100644 --- a/lib/application.dart +++ b/lib/application.dart @@ -3,19 +3,51 @@ import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/bootstrap.dart'; +import 'package:fl_clash/common/system_dns.dart'; import 'package:fl_clash/l10n/l10n.dart'; import 'package:fl_clash/manager/hotkey_manager.dart'; import 'package:fl_clash/manager/manager.dart'; import 'package:fl_clash/plugins/app.dart'; import 'package:fl_clash/providers/providers.dart'; import 'package:fl_clash/state.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'pages/pages.dart'; +Widget buildManagerStack({ + required bool isDesktop, + required Future Function(List results) + onConnectivityChanged, + required Widget child, +}) { + final platformApp = isDesktop + ? WindowHeaderContainer(child: child) + : VpnManager(child: child); + final state = AppStateManager( + child: CoreManager( + child: ConnectivityManager( + onConnectivityChanged: onConnectivityChanged, + child: platformApp, + ), + ), + ); + final platformState = isDesktop + ? WindowManager( + child: TrayManager( + child: HotKeyManager(child: ProxyManager(child: state)), + ), + ) + : AndroidManager(child: TileManager(child: state)); + return AppEnvManager( + child: LocaleManager( + child: StatusManager(child: ThemeManager(child: platformState)), + ), + ); +} + class Application extends ConsumerStatefulWidget { const Application({super.key}); @@ -36,10 +68,7 @@ class ApplicationState extends ConsumerState { }, ); - ColorScheme _getAppColorScheme({ - required Brightness brightness, - int? primaryColor, - }) { + ColorScheme _getAppColorScheme({required Brightness brightness}) { return ref.read(genColorSchemeProvider(brightness)); } @@ -49,19 +78,19 @@ class ApplicationState extends ConsumerState { SystemNavigator.setFrameworkHandlesBack(true); WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { if (globalState.navigatorKey.currentContext != null) { - await globalState.attach(); + await bootstrap.attach(); } else { exit(0); } _autoUpdateProfilesTask(); _initLink(); - app?.initShortcuts(); + unawaited(app?.initShortcuts()); }); } void _initLink() { linkManager.initAppLinksListen((url) async { - final res = await globalState.showMessage( + final res = await dialogs.showMessage( title: currentAppLocalizations.addProfile, message: TextSpan( children: [ @@ -79,56 +108,33 @@ class ApplicationState extends ConsumerState { ), ); if (res != true) return; - ref.read(profilesActionProvider.notifier).addProfileFormURL(url); + unawaited( + ref.read(profilesActionProvider.notifier).addProfileFormURL(url), + ); }); } void _autoUpdateProfilesTask() { _autoUpdateProfilesTaskTimer = Timer(const Duration(minutes: 20), () async { await ref.read(profilesActionProvider.notifier).autoUpdateProfiles(); + if (!mounted) { + return; + } _autoUpdateProfilesTask(); }); } - Widget _buildPlatformState({required Widget child}) { - if (system.isDesktop) { - return WindowManager( - child: TrayManager( - child: HotKeyManager(child: ProxyManager(child: child)), - ), - ); + Future _handleConnectivityChanged( + List results, + ) async { + commonPrint.log('connectivityChanged ${results.toString()}'); + unawaited(systemDnsCoordinator?.resync() ?? Future.value()); + unawaited(ref.read(systemActionProvider.notifier).updateLocalIp()); + final hasVpn = results.contains(ConnectivityResult.vpn); + if (_preHasVpn == hasVpn) { + ref.read(checkIpNumProvider.notifier).add(); } - return AndroidManager(child: TileManager(child: child)); - } - - Widget _buildState({required Widget child}) { - return AppStateManager( - child: CoreManager( - child: ConnectivityManager( - onConnectivityChanged: (results) async { - commonPrint.log('connectivityChanged ${results.toString()}'); - ref.read(systemActionProvider.notifier).updateLocalIp(); - final hasVpn = results.contains(ConnectivityResult.vpn); - if (_preHasVpn == hasVpn) { - ref.read(checkIpNumProvider.notifier).add(); - } - _preHasVpn = hasVpn; - }, - child: child, - ), - ), - ); - } - - Widget _buildPlatformApp({required Widget child}) { - if (system.isDesktop) { - return WindowHeaderContainer(child: child); - } - return VpnManager(child: child); - } - - Widget _buildApp({required Widget child}) { - return StatusManager(child: ThemeManager(child: child)); + _preHasVpn = hasVpn; } @override @@ -145,40 +151,35 @@ class ApplicationState extends ConsumerState { onNavigationNotification: (_) => true, localizationsDelegates: const [ AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, + ...GlobalMaterialLocalizations.delegates, ], builder: (_, child) { - return AppEnvManager( - child: _buildApp( - child: _buildPlatformState( - child: _buildState(child: _buildPlatformApp(child: child!)), - ), + // ignore: deprecated_member_use + return MaterialUiCompatibilityBridge( + child: buildManagerStack( + isDesktop: system.isDesktop, + onConnectivityChanged: _handleConnectivityChanged, + child: child!, ), ); }, scrollBehavior: BaseScrollBehavior(), title: appName, - locale: utils.getLocaleForString(locale), + locale: getLocaleForString(locale), supportedLocales: AppLocalizations.delegate.supportedLocales, themeMode: themeProps.themeMode, theme: ThemeData( useMaterial3: true, pageTransitionsTheme: _pageTransitionsTheme, - colorScheme: _getAppColorScheme( - brightness: Brightness.light, - primaryColor: themeProps.primaryColor, - ), - ), + colorScheme: _getAppColorScheme(brightness: Brightness.light), + ).withAppShapes, darkTheme: ThemeData( useMaterial3: true, pageTransitionsTheme: _pageTransitionsTheme, colorScheme: _getAppColorScheme( brightness: Brightness.dark, - primaryColor: themeProps.primaryColor, ).toPureBlack(themeProps.pureBlack), - ), + ).withAppShapes, home: child!, ); }, diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart new file mode 100644 index 0000000000..2995b746cf --- /dev/null +++ b/lib/bootstrap.dart @@ -0,0 +1,242 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:fl_clash/common/boot_guard.dart'; +import 'package:fl_clash/common/boot_record.dart'; +import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/common/launch.dart'; +import 'package:fl_clash/common/migration.dart'; +import 'package:fl_clash/common/permission.dart'; +import 'package:fl_clash/common/tray.dart'; +import 'package:fl_clash/common/window.dart'; +import 'package:fl_clash/database/database.dart'; +import 'package:fl_clash/enum/enum.dart'; +import 'package:fl_clash/l10n/l10n.dart'; +import 'package:fl_clash/models/models.dart'; +import 'package:fl_clash/providers/providers.dart'; +import 'package:fl_clash/state.dart'; +import 'package:fl_clash/views/navigation.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_color_utilities/palettes/core_palette.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class Bootstrap { + static Bootstrap? _instance; + + Bootstrap._internal(); + + factory Bootstrap() { + _instance ??= Bootstrap._internal(); + return _instance!; + } + + BootDecision _bootDecision = const BootDecision(); + + Future init(int version) async { + globalState.appEnv = const String.fromEnvironment( + 'APP_ENV', + defaultValue: 'pre', + ); + windowPort = window; + trayPort = appTray; + navigationPort = navigation; + final dynamicColor = await _initDynamicColor(); + return _initData(version, dynamicColor); + } + + Future _initDynamicColor() async { + // ignore: deprecated_member_use + CorePalette? corePalette; + Color? accentColor; + try { + corePalette = await DynamicColorPlugin.getCorePalette(); + } catch (error) { + commonPrint.log( + 'Failed to get core palette: $error', + logLevel: LogLevel.warning, + ); + } + try { + accentColor = await DynamicColorPlugin.getAccentColor(); + } catch (error) { + commonPrint.log( + 'Failed to get accent color: $error', + logLevel: LogLevel.warning, + ); + } + return ( + lightSeed: corePalette?.toColorScheme().primary, + darkSeed: corePalette?.toColorScheme(brightness: Brightness.dark).primary, + accentColor: accentColor ?? const Color(defaultPrimaryColor), + ); + } + + Future _initData( + int version, + DynamicColorSeeds dynamicColor, + ) async { + globalState.packageInfo = await PackageInfo.fromPlatform(); + var config = await migration.run(); + _bootDecision = await bootGuard.evaluate( + profileId: config.currentProfileId, + crashlyticsEnabled: config.appSettingProps.crashlytics, + ); + if (_bootDecision.recovery == BootRecovery.clearProfile) { + config = config.copyWith(currentProfileId: null); + await preferences.saveConfig(config); + } + final appState = AppState( + brightness: WidgetsBinding.instance.platformDispatcher.platformBrightness, + version: version, + viewSize: Size.zero, + requests: FixedList(maxLength), + logs: FixedList(maxLength), + traffics: FixedList(trafficSampleLength), + totalTraffic: const Traffic(), + systemUiOverlayStyle: const SystemUiOverlayStyle(), + ); + final appStateOverrides = buildAppStateOverrides(appState); + final configOverrides = buildConfigOverrides(config); + final container = ProviderContainer( + overrides: [...appStateOverrides, ...configOverrides], + ); + globalState.container = container; + container + .read(dynamicColorProvider.notifier) + .seed( + lightSeed: dynamicColor.lightSeed, + darkSeed: dynamicColor.darkSeed, + accentColor: dynamicColor.accentColor, + ); + final profiles = await database.profilesDao.query().get(); + container.read(profilesProvider.notifier).setAndReorder(profiles); + await AppLocalizations.load( + getLocaleForString(config.appSettingProps.locale) ?? + WidgetsBinding.instance.platformDispatcher.locale, + ); + await window?.init(version, config.windowProps); + if (system.isAndroid) { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } + return container; + } + + Future attach() async { + if (globalState.isAttach == true) { + return; + } + await _initApp(); + globalState.isAttach = true; + } + + ProviderContainer get _container => globalState.container; + + Future _initApp() async { + unawaited(_container.read(systemActionProvider.notifier).updateTray()); + unawaited( + _container.read(profilesActionProvider.notifier).autoUpdateProfiles(), + ); + unawaited(_container.read(commonActionProvider.notifier).autoCheckUpdate()); + unawaited( + autoLaunch?.updateStatus(_container.read(appSettingProvider).autoLaunch), + ); + if (!_container.read(appSettingProvider).silentLaunch) { + unawaited(window?.show()); + } else { + unawaited(window?.hide()); + } + await _handleFailedPreference(); + await _handlerDisclaimer(); + await _showCrashRecoveryTip(); + await _showCrashlyticsTip(); + await _container.read(coreActionProvider.notifier).startCore(); + if (!_bootDecision.isDegraded) { + await _container.read(setupActionProvider.notifier).initStatus(); + } + _container.read(initProvider.notifier).value = true; + await bootGuard.markRunning(); + permissions.check(_container.read); + } + + Future _showCrashRecoveryTip() async { + switch (_bootDecision.recovery) { + case BootRecovery.none: + return; + case BootRecovery.skipAutoSetup: + await dialogs.showMessage( + title: currentAppLocalizations.launchInterrupted, + cancelable: false, + dismissible: false, + message: TextSpan(text: currentAppLocalizations.launchInterruptedTip), + ); + case BootRecovery.clearProfile: + await dialogs.showMessage( + title: currentAppLocalizations.crashDetected, + cancelable: false, + dismissible: false, + message: TextSpan( + text: currentAppLocalizations.crashDetectedTip(_failedProfileLabel), + ), + ); + } + } + + String get _failedProfileLabel { + final profileId = _bootDecision.failedProfileId; + if (profileId == null) { + return ''; + } + final profile = _container.read(profilesProvider).getProfile(profileId); + return profile?.label.takeFirstValid(['$profileId']) ?? '$profileId'; + } + + Future _handleFailedPreference() async { + if (await preferences.isInit) return; + final res = await dialogs.showMessage( + title: currentAppLocalizations.tip, + message: TextSpan(text: currentAppLocalizations.cacheCorrupt), + ); + if (res == true) { + final file = File(await appPath.sharedPreferencesPath); + await file.safeDelete(); + } + await _container.read(systemActionProvider.notifier).handleExit(); + } + + Future _showCrashlyticsTip() async { + if (!system.isAndroid) return; + if (_container.read( + appSettingProvider.select((state) => state.crashlyticsTip), + )) { + return; + } + await dialogs.showMessage( + title: currentAppLocalizations.dataCollectionTip, + cancelable: false, + message: TextSpan(text: currentAppLocalizations.dataCollectionContent), + ); + _container + .read(appSettingProvider.notifier) + .update((state) => state.copyWith(crashlyticsTip: true)); + } + + Future _handlerDisclaimer() async { + if (_container.read( + appSettingProvider.select((state) => state.disclaimerAccepted), + )) { + return; + } + final isDisclaimerAccepted = await dialogs.showDisclaimer(); + if (!isDisclaimerAccepted) { + await _container.read(systemActionProvider.notifier).handleExit(); + } + _container + .read(appSettingProvider.notifier) + .update((state) => state.copyWith(disclaimerAccepted: true)); + } +} + +final bootstrap = Bootstrap(); diff --git a/lib/common/app_localizations.dart b/lib/common/app_localizations.dart index bf2f519a69..aec4ac3e42 100644 --- a/lib/common/app_localizations.dart +++ b/lib/common/app_localizations.dart @@ -1,3 +1,50 @@ +import 'package:dio/dio.dart'; +import 'package:fl_clash/core/method.dart'; import 'package:fl_clash/l10n/l10n.dart'; +import 'dart:ui'; + final currentAppLocalizations = AppLocalizations.current; + +String? networkErrorMessage(Object error, AppLocalizations appLocalizations) { + if (error case CoreMethodException(:final code)) { + return switch (code) { + 'request_bad_response' => appLocalizations.networkException, + 'request_error' => appLocalizations.unknownNetworkError, + _ => null, + }; + } + if (error is DioException) { + return error.type == DioExceptionType.badResponse + ? appLocalizations.networkException + : appLocalizations.unknownNetworkError; + } + return null; +} + +String userFacingErrorMessage(Object error, AppLocalizations appLocalizations) { + return networkErrorMessage(error, appLocalizations) ?? + switch (error) { + CoreMethodException(:final message) => message, + _ => error.toString(), + }; +} + +Locale? getLocaleForString(String? localString) { + if (localString == null) return null; + final localSplit = localString.split('_'); + if (localSplit.length == 1) { + return Locale(localSplit[0]); + } + if (localSplit.length == 2) { + return Locale(localSplit[0], localSplit[1]); + } + if (localSplit.length == 3) { + return Locale.fromSubtags( + languageCode: localSplit[0], + scriptCode: localSplit[1], + countryCode: localSplit[2], + ); + } + return null; +} diff --git a/lib/common/app_ports.dart b/lib/common/app_ports.dart new file mode 100644 index 0000000000..215d0c5d79 --- /dev/null +++ b/lib/common/app_ports.dart @@ -0,0 +1,32 @@ +import 'package:fl_clash/common/provider_reader.dart'; +import 'package:fl_clash/models/models.dart'; + +abstract interface class WindowPort { + Future show(); + + Future hide(); + + Future close(); + + Future get isVisible; + + void forceExit(); +} + +abstract interface class TrayPort { + Future shutdown(); + + Future update({ + required TrayState trayState, + required Traffic traffic, + required ProviderReader read, + }); +} + +abstract interface class NavigationPort { + List getItems({bool openLogs, bool hasProxies}); +} + +WindowPort? windowPort; +TrayPort? trayPort; +NavigationPort? navigationPort; diff --git a/lib/common/archive.dart b/lib/common/archive.dart deleted file mode 100644 index 2f05996ce2..0000000000 --- a/lib/common/archive.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:io'; - -import 'package:archive/archive_io.dart'; -import 'package:path/path.dart'; - -extension ArchiveExt on Archive { - void addDirectoryToArchive(String dirPath, String parentPath) { - final dir = Directory(dirPath); - if (!dir.existsSync()) { - return; - } - final entities = dir.listSync(recursive: false); - for (final entity in entities) { - final relativePath = relative(entity.path, from: parentPath); - if (entity is File) { - final data = entity.readAsBytesSync(); - final archiveFile = ArchiveFile(relativePath, data.length, data); - addFile(archiveFile); - } - } - } -} diff --git a/lib/common/boot_guard.dart b/lib/common/boot_guard.dart new file mode 100644 index 0000000000..c24ab64366 --- /dev/null +++ b/lib/common/boot_guard.dart @@ -0,0 +1,117 @@ +import 'dart:math'; + +import 'package:fl_clash/common/boot_record.dart'; +import 'package:fl_clash/common/preferences.dart'; +import 'package:fl_clash/common/print.dart'; +import 'package:fl_clash/common/system.dart'; +import 'package:fl_clash/enum/enum.dart'; + +class BootGuard { + final bool _supported; + final Future Function() _readRecord; + final Future Function(BootRecord record) _writeRecord; + final Future Function() _readExitInfo; + final Future Function() _readCrashReport; + final int Function() _now; + + BootDecision _decision = const BootDecision(); + + BootGuard({ + bool? supported, + Future Function()? readRecord, + Future Function(BootRecord record)? writeRecord, + Future Function()? readExitInfo, + Future Function()? readCrashReport, + int Function()? now, + }) : _supported = supported ?? system.isAndroid, + _readRecord = readRecord ?? preferences.getBootRecord, + _writeRecord = writeRecord ?? preferences.saveBootRecord, + _readExitInfo = readExitInfo ?? system.lastExitInfo, + _readCrashReport = readCrashReport ?? system.didCrashOnPreviousExecution, + _now = now ?? _currentMilliseconds; + + static int _currentMilliseconds() => DateTime.now().millisecondsSinceEpoch; + + BootDecision get decision => _decision; + + Future evaluate({ + required int? profileId, + required bool crashlyticsEnabled, + }) async { + if (!_supported) { + return _decision; + } + final record = await _readRecord(); + final exitInfo = await _readExitInfo(); + final crashReported = crashlyticsEnabled && await _readCrashReport(); + final decision = resolveBootDecision( + record: record, + exitInfo: exitInfo, + crashReported: crashReported, + ); + if (decision.isDegraded) { + commonPrint.log( + 'Previous launch did not finish: $decision', + logLevel: LogLevel.warning, + ); + } + await _writeRecord( + BootRecord( + stage: BootStage.starting, + profileId: decision.recovery == BootRecovery.clearProfile + ? null + : profileId, + startedAt: _now(), + failureCount: decision.failureCount, + lastFailedProfileId: + decision.failedProfileId ?? record?.lastFailedProfileId, + handledExitAt: max( + record?.handledExitAt ?? 0, + exitInfo?.timestamp ?? 0, + ), + ), + ); + _decision = decision; + return decision; + } + + Future markRunning() async { + if (!_supported) { + return; + } + final record = await _readRecord(); + if (record == null) { + return; + } + await _writeRecord( + BootRecord( + stage: BootStage.running, + profileId: record.profileId, + startedAt: record.startedAt, + failureCount: _decision.isDegraded ? record.failureCount : 0, + lastFailedProfileId: record.lastFailedProfileId, + handledExitAt: record.handledExitAt, + ), + ); + } + + Future markClosed() async { + if (!_supported) { + return; + } + final record = await _readRecord(); + if (record == null) { + return; + } + await _writeRecord( + BootRecord( + profileId: record.profileId, + startedAt: record.startedAt, + lastFailedProfileId: record.lastFailedProfileId, + handledExitAt: record.handledExitAt, + ), + ); + } +} + +final bootGuard = BootGuard(); diff --git a/lib/common/boot_record.dart b/lib/common/boot_record.dart new file mode 100644 index 0000000000..f1a500249a --- /dev/null +++ b/lib/common/boot_record.dart @@ -0,0 +1,225 @@ +const crashRecoveryClearThreshold = 2; + +enum AppExitReason { + unknown, + exitSelf, + signaled, + lowMemory, + crash, + crashNative, + anr, + initializationFailure, + permissionChange, + excessiveResourceUsage, + userRequested, + userStopped, + dependencyDied, + other, + freezer, + packageStateChange, + packageUpdated; + + static AppExitReason fromCode(Object? code) => switch (code) { + 1 => exitSelf, + 2 => signaled, + 3 => lowMemory, + 4 => crash, + 5 => crashNative, + 6 => anr, + 7 => initializationFailure, + 8 => permissionChange, + 9 => excessiveResourceUsage, + 10 => userRequested, + 11 => userStopped, + 12 => dependencyDied, + 13 => other, + 14 => freezer, + 15 => packageStateChange, + 16 => packageUpdated, + _ => unknown, + }; + + bool get isCrash => switch (this) { + crash || crashNative || anr || initializationFailure => true, + _ => false, + }; + + bool get isExternalStop => switch (this) { + exitSelf || + signaled || + lowMemory || + permissionChange || + excessiveResourceUsage || + userRequested || + userStopped || + dependencyDied || + freezer || + packageStateChange || + packageUpdated => true, + _ => false, + }; +} + +class AppExitInfo { + final AppExitReason reason; + final int timestamp; + final String? description; + + const AppExitInfo({ + required this.reason, + required this.timestamp, + this.description, + }); + + static AppExitInfo? fromJson(Object? json) { + if (json is! Map) { + return null; + } + final timestamp = json['timestamp']; + if (timestamp is! int) { + return null; + } + final description = json['description']; + return AppExitInfo( + reason: AppExitReason.fromCode(json['reason']), + timestamp: timestamp, + description: description is String ? description : null, + ); + } + + @override + String toString() => + 'AppExitInfo(${reason.name}, $timestamp, ${description ?? '-'})'; +} + +enum BootStage { starting, running } + +class BootRecord { + final BootStage? stage; + final int? profileId; + final int startedAt; + final int failureCount; + final int? lastFailedProfileId; + final int handledExitAt; + + const BootRecord({ + this.stage, + this.profileId, + this.startedAt = 0, + this.failureCount = 0, + this.lastFailedProfileId, + this.handledExitAt = 0, + }); + + static BootRecord? fromJson(Object? json) { + if (json is! Map) { + return null; + } + final stage = json['stage']; + return BootRecord( + stage: BootStage.values.where((value) => value.name == stage).firstOrNull, + profileId: json['profileId'] is int ? json['profileId'] as int : null, + startedAt: json['startedAt'] is int ? json['startedAt'] as int : 0, + failureCount: json['failureCount'] is int + ? json['failureCount'] as int + : 0, + lastFailedProfileId: json['lastFailedProfileId'] is int + ? json['lastFailedProfileId'] as int + : null, + handledExitAt: json['handledExitAt'] is int + ? json['handledExitAt'] as int + : 0, + ); + } + + Map toJson() => { + 'stage': stage?.name, + 'profileId': profileId, + 'startedAt': startedAt, + 'failureCount': failureCount, + 'lastFailedProfileId': lastFailedProfileId, + 'handledExitAt': handledExitAt, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BootRecord && + other.stage == stage && + other.profileId == profileId && + other.startedAt == startedAt && + other.failureCount == failureCount && + other.lastFailedProfileId == lastFailedProfileId && + other.handledExitAt == handledExitAt; + + @override + int get hashCode => Object.hash( + stage, + profileId, + startedAt, + failureCount, + lastFailedProfileId, + handledExitAt, + ); + + @override + String toString() => + 'BootRecord(${stage?.name ?? 'closed'}, profile: $profileId, ' + 'failures: $failureCount)'; +} + +enum BootRecovery { none, skipAutoSetup, clearProfile } + +class BootDecision { + final BootRecovery recovery; + final int failureCount; + final int? failedProfileId; + final bool crashConfirmed; + final AppExitReason? exitReason; + + const BootDecision({ + this.recovery = BootRecovery.none, + this.failureCount = 0, + this.failedProfileId, + this.crashConfirmed = false, + this.exitReason, + }); + + bool get isDegraded => recovery != BootRecovery.none; + + @override + String toString() => + 'BootDecision(${recovery.name}, failures: $failureCount, ' + 'profile: $failedProfileId, confirmed: $crashConfirmed, ' + 'exit: ${exitReason?.name ?? '-'})'; +} + +BootDecision resolveBootDecision({ + required BootRecord? record, + required AppExitInfo? exitInfo, + required bool crashReported, +}) { + if (record == null || record.stage != BootStage.starting) { + return const BootDecision(); + } + final matchesLastRun = + exitInfo != null && + exitInfo.timestamp > record.handledExitAt && + exitInfo.timestamp >= record.startedAt; + final reason = matchesLastRun ? exitInfo.reason : null; + if (reason != null && reason.isExternalStop) { + return BootDecision(exitReason: reason); + } + final failureCount = record.failureCount + 1; + final shouldClear = + failureCount >= crashRecoveryClearThreshold && record.profileId != null; + return BootDecision( + recovery: shouldClear + ? BootRecovery.clearProfile + : BootRecovery.skipAutoSetup, + failureCount: failureCount, + failedProfileId: record.profileId, + crashConfirmed: (reason?.isCrash ?? false) || crashReported, + exitReason: reason, + ); +} diff --git a/lib/common/cache.dart b/lib/common/cache.dart index d677ac2011..13424bb457 100644 --- a/lib/common/cache.dart +++ b/lib/common/cache.dart @@ -50,6 +50,6 @@ extension CacheManagerExt on CacheManager { ); } } - streamController.close(); + unawaited(streamController.close()); } } diff --git a/lib/common/changelog.dart b/lib/common/changelog.dart new file mode 100644 index 0000000000..9c0a38f1e9 --- /dev/null +++ b/lib/common/changelog.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:fl_clash/enum/enum.dart'; +import 'package:fl_clash/l10n/l10n.dart'; +import 'package:fl_clash/models/changelog.dart'; + +import 'common.dart'; + +const releaseChangelogJsonMarker = ''; + +String changelogGroupTitle( + AppLocalizations appLocalizations, + ChangelogType type, +) => switch (type) { + ChangelogType.breaking => appLocalizations.changelogBreaking, + ChangelogType.feat => appLocalizations.changelogFeatures, + ChangelogType.fix => appLocalizations.changelogFixes, + ChangelogType.perf => appLocalizations.changelogPerformance, + ChangelogType.revert => appLocalizations.changelogReverts, + ChangelogType.unknown => '', +}; + +ChangelogVersion? parseReleaseChangelog(String? body) { + if (body == null) { + return null; + } + final begin = body.indexOf(releaseChangelogJsonMarker); + if (begin < 0) { + return null; + } + final start = begin + releaseChangelogJsonMarker.length; + final end = body.indexOf(_releaseChangelogJsonEndMarker, start); + if (end < 0) { + return null; + } + try { + final changelog = Changelog.fromJson( + jsonDecode(body.substring(start, end)) as Map, + ); + if (!changelog.isSupported) { + commonPrint.log( + 'changelog schema ${changelog.schemaVersion} is not supported', + logLevel: LogLevel.warning, + ); + return null; + } + return changelog.versions.firstOrNull; + } catch (error) { + commonPrint.log( + 'changelog decode failed ${compactError(error)}', + logLevel: LogLevel.warning, + ); + return null; + } +} diff --git a/lib/common/color.dart b/lib/common/color.dart index 4df12565b6..7132deb92c 100644 --- a/lib/common/color.dart +++ b/lib/common/color.dart @@ -1,6 +1,6 @@ import 'dart:math'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; extension ColorExtension on Color { Color get opacity80 { @@ -101,15 +101,6 @@ extension ColorExtension on Color { factor, )!; } - - Color blendLighten(BuildContext context, {double factor = 0.1}) { - final brightness = Theme.of(context).brightness; - return Color.lerp( - this, - brightness == Brightness.dark ? Colors.black : Colors.white, - factor, - )!; - } } extension ColorSchemeExtension on ColorScheme { @@ -120,3 +111,10 @@ extension ColorSchemeExtension on ColorScheme { ) : this; } + +Color? getDelayColor(int? delay) { + if (delay == null) return null; + if (delay < 0) return Colors.red; + if (delay < 600) return Colors.green; + return const Color(0xFFC57F0A); +} diff --git a/lib/common/common.dart b/lib/common/common.dart index f606be9ad0..a8ac778350 100644 --- a/lib/common/common.dart +++ b/lib/common/common.dart @@ -1,10 +1,14 @@ export 'app_localizations.dart'; +export 'app_ports.dart'; +export 'changelog.dart'; export 'color.dart'; export 'compute.dart'; export 'constant.dart'; export 'context.dart'; export 'converter.dart'; export 'datetime.dart'; +export 'dialog.dart'; +export 'exception.dart'; export 'file.dart'; export 'fixed.dart'; export 'function.dart'; @@ -16,32 +20,30 @@ export 'input_limits.dart'; export 'iterable.dart'; export 'javascript.dart'; export 'keyboard.dart'; -export 'launch.dart'; +export 'layout.dart'; export 'link.dart'; export 'lock.dart'; export 'measure.dart'; export 'mixin.dart'; -export 'navigation.dart'; export 'navigator.dart'; export 'network.dart'; export 'num.dart'; export 'package.dart'; export 'path.dart'; -export 'permission.dart'; export 'picker.dart'; export 'preferences.dart'; export 'print.dart'; export 'protocol.dart'; +export 'provider_reader.dart'; export 'proxy.dart'; export 'render.dart'; export 'request.dart'; export 'scroll.dart'; +export 'shape.dart'; export 'snowflake.dart'; export 'string.dart'; export 'system.dart'; export 'task.dart'; +export 'task_pool.dart'; export 'text.dart'; -export 'tray.dart'; -export 'utils.dart'; -export 'window.dart'; export 'yaml.dart'; diff --git a/lib/common/compute.dart b/lib/common/compute.dart index 1564a72132..0a51d499ee 100644 --- a/lib/common/compute.dart +++ b/lib/common/compute.dart @@ -91,6 +91,10 @@ SelectedProxyState computeRealSelectedProxyState( ); } +String delayTestKey(String testUrl, String proxyName) { + return '$testUrl\u0000$proxyName'; +} + DelayState computeProxyDelayState({ required String proxyName, required String testUrl, diff --git a/lib/common/constant.dart b/lib/common/constant.dart index f7abf870fc..d810047fbe 100644 --- a/lib/common/constant.dart +++ b/lib/common/constant.dart @@ -7,7 +7,7 @@ import 'package:collection/collection.dart'; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/models/models.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; const appName = 'FlClash'; const appHelperService = 'FlClashHelperService'; @@ -47,13 +47,16 @@ String _randomPipeId() { final defaultTextScaleFactor = WidgetsBinding.instance.platformDispatcher.textScaleFactor; + const httpTimeoutDuration = Duration(milliseconds: 5000); -/// Keep at or below the Core's delay-test concurrency (`mBatch` in -/// core/common.go). Surplus requests queue inside the Core behind a full wave -/// of 5s timeouts, which no RPC timeout can cover. -const maxConcurrentDelayTests = 50; -const moreDuration = Duration(milliseconds: 100); +const delayTestGuardDuration = Duration(seconds: 30); + +const coreConnectionWaitDuration = Duration(seconds: 10); + +/// Keep at or below the Core's delay-test concurrency (`delayTestConcurrency` +/// in core/common.go). +const maxConcurrentDelayTests = 16; const animateDuration = Duration(milliseconds: 100); const midDuration = Duration(milliseconds: 200); const commonDuration = Duration(milliseconds: 300); @@ -62,18 +65,22 @@ const MMDB = 'GEOIP.metadb'; const ASN = 'ASN.mmdb'; const GEOIP = 'GEOIP.dat'; const GEOSITE = 'GEOSITE.dat'; -final double kHeaderHeight = system.isDesktop - ? !system.isMacOS - ? 40 - : 28 - : 0; +final double kHeaderHeight = getWindowHeaderHeight( + isDesktop: system.isDesktop, + isMacOS: system.isMacOS, +); const profilesDirectoryName = 'profiles'; +const providersDirectoryName = 'providers'; +const proxiesProviderDirectoryName = 'proxies'; +const rulesProviderDirectoryName = 'rules'; const localhost = '127.0.0.1'; const clashConfigKey = 'clash_config'; const configKey = 'config'; +const systemDnsRecordKey = 'system_dns_record'; +const bootRecordKey = 'boot_record'; +const defaultSystemDnsFallback = '223.5.5.5'; const double dialogCommonWidth = 300; const repository = 'chen08209/FlClash'; -const defaultExternalController = '127.0.0.1:9090'; const maxMobileWidth = 600; const maxLaptopWidth = 840; const defaultTestUrl = 'https://www.gstatic.com/generate_204'; @@ -83,37 +90,19 @@ final commonFilter = ImageFilter.blur( tileMode: TileMode.clamp, ); -const listEquality = ListEquality(); -const navigationItemListEquality = ListEquality(); const trackerInfoListEquality = ListEquality(); const stringListEquality = ListEquality(); const intListEquality = ListEquality(); const logListEquality = ListEquality(); -const groupListEquality = ListEquality(); const ruleListEquality = ListEquality(); const scriptListEquality = ListEquality