Compare commits
13 Commits
main
...
v0.8.97-pre.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f659cd85a | |||
| 37730872ac | |||
| cb84bba1bf | |||
| cf36b3ce09 | |||
| 5d2477be16 | |||
| e7697b8762 | |||
| ce327d7937 | |||
| 00d45ad362 | |||
| 5589c76921 | |||
| 576b28720f | |||
| f14e6f1460 | |||
| e40e8de16c | |||
| db6f5d9036 |
+195
-7
@@ -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
|
||||
|
||||
+69
-1
@@ -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
|
||||
(`<!-- flclash:changelog:json … -->`), 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 `<!-- changelog:frozen -->` 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<pubspec version>` 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.
|
||||
|
||||
+115
-2
@@ -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/<name>-<version> ~/.pub-cache/git/<name>-<sha>
|
||||
```
|
||||
|
||||
`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<Log> 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
|
||||
|
||||
|
||||
+329
-7
@@ -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<MessageException>().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<void> 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
|
||||
<type>[(scope)][!]: <description>
|
||||
```
|
||||
|
||||
- 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-<locale>:` or `Breaking-<locale>:` 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(<runtime string>)`
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
+114
-86
@@ -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 }}
|
||||
|
||||
@@ -68,7 +68,6 @@ docs/
|
||||
/macos/build/
|
||||
/env.json
|
||||
/core_sha256.json
|
||||
/core/*.exe
|
||||
/plugins/rust_api/.claude/
|
||||
devtools_options.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]
|
||||
|
||||
@@ -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
|
||||
|
||||
+110
-417
File diff suppressed because it is too large
Load Diff
+8
-35
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
private val machine = ServiceStateMachine(AndroidServiceStateHost)
|
||||
|
||||
@Volatile
|
||||
private var sharedState = SharedState()
|
||||
val runState = machine.runState
|
||||
|
||||
@Volatile
|
||||
private var flutterEngine: FlutterEngine? = null
|
||||
fun attachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.attachFlutterEngine(engine)
|
||||
|
||||
val runState = mutableRunState.asStateFlow()
|
||||
fun detachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.detachFlutterEngine(engine)
|
||||
|
||||
private val runTimeMillis: Long
|
||||
get() = ServiceController.getRunTimeMillis()
|
||||
suspend fun handleToggleAction() = machine.handleToggleAction()
|
||||
|
||||
private val appPlugin: AppPlugin?
|
||||
get() = flutterEngine?.plugin<AppPlugin>()
|
||||
suspend fun handleStartAction() = machine.handleStartAction()
|
||||
|
||||
private val tilePlugin: TilePlugin?
|
||||
get() = flutterEngine?.plugin<TilePlugin>()
|
||||
suspend fun handleStopAction() = machine.handleStopAction()
|
||||
|
||||
fun attachFlutterEngine(engine: FlutterEngine) {
|
||||
flutterEngine = engine
|
||||
}
|
||||
suspend fun handleVpnRevokeAction() = machine.handleVpnRevokeAction()
|
||||
|
||||
fun detachFlutterEngine(engine: FlutterEngine) {
|
||||
if (flutterEngine === engine) {
|
||||
flutterEngine = null
|
||||
}
|
||||
}
|
||||
suspend fun refresh(): Long = machine.refresh()
|
||||
|
||||
suspend fun handleToggleAction() {
|
||||
if (isRunningRequested()) {
|
||||
handleStopAction()
|
||||
} else {
|
||||
handleStartAction()
|
||||
}
|
||||
}
|
||||
fun requestStart(): Deferred<Boolean> = machine.requestStart()
|
||||
|
||||
suspend fun refresh(): Long = transitionLock.withLock {
|
||||
val current = runTimeMillis
|
||||
mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED
|
||||
current
|
||||
}
|
||||
fun requestStop(): Deferred<Boolean> = machine.requestStop()
|
||||
|
||||
internal fun captureRequestToken(): RunRequest = latestRequest.get()
|
||||
fun syncSharedState(state: SharedState) = machine.syncSharedState(state)
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
internal fun captureRequestToken(): RunRequest = machine.captureRequestToken()
|
||||
|
||||
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<Boolean> {
|
||||
val request = createRequest(running = true)
|
||||
val result = CompletableDeferred<Boolean>()
|
||||
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<Boolean> {
|
||||
val request = createRequest(running = false)
|
||||
val result = CompletableDeferred<Boolean>()
|
||||
GlobalState.launch {
|
||||
result.complete(
|
||||
runCatching { stop(request) }
|
||||
.onFailure { error ->
|
||||
GlobalState.log("Unable to process service stop request: $error")
|
||||
}
|
||||
.getOrDefault(false),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun syncSharedState(state: SharedState) {
|
||||
sharedState = state
|
||||
applySharedState()
|
||||
}
|
||||
|
||||
private suspend fun loadPreferencesAndStart() {
|
||||
sharedState = GlobalState.application.sharedState
|
||||
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
|
||||
GlobalState.application.showToast(MISSING_CONFIG_MESSAGE)
|
||||
return
|
||||
}
|
||||
if (setupCore()) {
|
||||
if (!requestStart().await()) {
|
||||
GlobalState.application.showToast(START_FAILED_MESSAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySharedState() {
|
||||
GlobalState.setCrashlytics(sharedState.crashlytics)
|
||||
ServiceConfig.updateNotificationParams(
|
||||
NotificationParams(
|
||||
title = sharedState.currentProfileName,
|
||||
stopText = sharedState.stopText,
|
||||
onlyStatisticsProxy = sharedState.onlyStatisticsProxy,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun setupCore(): Boolean {
|
||||
applySharedState()
|
||||
GlobalState.application.showToast(sharedState.startTip)
|
||||
val initParams = Gson().toJson(
|
||||
mapOf(
|
||||
"home-dir" to GlobalState.application.filesDir.path,
|
||||
"version" to android.os.Build.VERSION.SDK_INT,
|
||||
),
|
||||
)
|
||||
val setupParams = Gson().toJson(sharedState.setupParams)
|
||||
return ServiceController.quickSetup(
|
||||
initParams,
|
||||
setupParams,
|
||||
).fold(
|
||||
onSuccess = { message ->
|
||||
if (message.isEmpty()) {
|
||||
true
|
||||
} else {
|
||||
GlobalState.log("Unable to set up core: $message")
|
||||
showConfigError(message)
|
||||
false
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
GlobalState.log("Unable to set up core: $error")
|
||||
showConfigError(error.message)
|
||||
false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun showConfigError(message: String?) {
|
||||
GlobalState.application.showToast(
|
||||
message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock {
|
||||
if (!isCurrent(request)) {
|
||||
return@withLock false
|
||||
}
|
||||
val options = sharedState.vpnOptions
|
||||
if (options == null) {
|
||||
fail(request)
|
||||
return@withLock false
|
||||
}
|
||||
if (!prepareVpn(options)) {
|
||||
if (appPlugin == null && isCurrent(request)) {
|
||||
GlobalState.application.showToast(VPN_PERMISSION_MESSAGE)
|
||||
}
|
||||
fail(request)
|
||||
return@withLock false
|
||||
}
|
||||
if (!isCurrent(request)) {
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
transitionLock.withLock transition@{
|
||||
if (!isCurrent(request)) {
|
||||
return@transition false
|
||||
}
|
||||
if (runState.value == RunState.STARTED && runTimeMillis != 0L) {
|
||||
return@transition true
|
||||
}
|
||||
mutableRunState.value = RunState.STARTING
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private suspend fun prepareVpn(options: VpnOptions): Boolean {
|
||||
val plugin = appPlugin
|
||||
?: return !options.enable || VpnService.prepare(GlobalState.application) == null
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val callback: (Boolean) -> Unit = { granted ->
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(granted)
|
||||
}
|
||||
}
|
||||
continuation.invokeOnCancellation {
|
||||
plugin.cancelVpnPreparation(callback)
|
||||
}
|
||||
plugin.prepareVpn(options.enable, callback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRequest(running: Boolean): RunRequest =
|
||||
RunRequest(running).also(latestRequest::set)
|
||||
|
||||
private fun isRunningRequested(): Boolean = latestRequest.get().running
|
||||
|
||||
private fun isCurrent(request: RunRequest): Boolean = latestRequest.get() === request
|
||||
|
||||
private fun fail(request: RunRequest) {
|
||||
latestRequest.compareAndSet(request, RunRequest(running = false))
|
||||
}
|
||||
|
||||
internal class RunRequest(
|
||||
val running: Boolean,
|
||||
)
|
||||
internal suspend fun handleServiceLost(token: RunRequest) = machine.handleServiceLost(token)
|
||||
}
|
||||
|
||||
@@ -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<String>
|
||||
|
||||
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<TilePlugin>()?.let { plugin ->
|
||||
object : TileGateway {
|
||||
override fun handleStart() = plugin.handleStart()
|
||||
|
||||
override fun handleStop() = plugin.handleStop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun app(): AppGateway? = flutterEngine?.plugin<AppPlugin>()?.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<String> =
|
||||
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()
|
||||
}
|
||||
@@ -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<Boolean> {
|
||||
val request = createRequest(running = true)
|
||||
val result = CompletableDeferred<Boolean>()
|
||||
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<Boolean> {
|
||||
val request = createRequest(running = false)
|
||||
val result = CompletableDeferred<Boolean>()
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Boolean>()
|
||||
|
||||
private var requestNotificationCallback: ((Boolean) -> Unit)? = null
|
||||
private val requestNotificationCallback = PendingCallback<Boolean>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun detachFromActivity() {
|
||||
activity = null
|
||||
val binding = activityBinding ?: return
|
||||
activityBinding = null
|
||||
binding.removeActivityResultListener(activityResultListener)
|
||||
binding.removeRequestPermissionsResultListener(permissionsResultListener)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String, Any?>? {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -100,6 +100,7 @@ static void call_tun_interface_protect_impl(void *tun_interface, const int fd) {
|
||||
env->CallVoidMethod(static_cast<jobject>(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<jobject>(invoke_interface),
|
||||
m_invoke_interface_result,
|
||||
value);
|
||||
env->DeleteLocalRef(value);
|
||||
jni_clear_exception(env);
|
||||
if (value != nullptr) {
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
}
|
||||
|
||||
extern "C"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <pthread.h>
|
||||
|
||||
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<jclass>(new_global(find_class("java/lang/String")));
|
||||
m_new_string = find_method(c_string, "<init>", "([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<char *>(calloc(1, 1));
|
||||
}
|
||||
|
||||
char *jni_get_string(JNIEnv *env, jstring str) {
|
||||
if (str == nullptr) {
|
||||
return static_cast<char *>(calloc(1, 1));
|
||||
return empty_string();
|
||||
}
|
||||
const auto array = reinterpret_cast<jbyteArray>(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<char *>(malloc(length + 1));
|
||||
if (content == nullptr) {
|
||||
env->DeleteLocalRef(array);
|
||||
return empty_string();
|
||||
}
|
||||
env->GetByteArrayRegion(array, 0, length, reinterpret_cast<jbyte *>(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<int>(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<const jbyte *>(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<jstring>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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); \
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-1
@@ -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<ConnectivityManager>()
|
||||
}
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var currentDnsList = listOf<String>()
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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<String>()
|
||||
val logs = mutableListOf<String>()
|
||||
val notificationParams = mutableListOf<NotificationParams>()
|
||||
val crashlytics = mutableListOf<Boolean>()
|
||||
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<String> {
|
||||
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<RunState>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
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<String>()
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean>()
|
||||
|
||||
assertFalse(slot.isPending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replace leaves the new callback pending`() {
|
||||
val slot = PendingCallback<Boolean>()
|
||||
|
||||
slot.replace({ }, supersededValue = false)
|
||||
|
||||
assertTrue(slot.isPending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve delivers the value once and empties the slot`() {
|
||||
val slot = PendingCallback<Boolean>()
|
||||
val received = mutableListOf<Boolean>()
|
||||
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<Boolean>()
|
||||
val first = mutableListOf<Boolean>()
|
||||
val second = mutableListOf<Boolean>()
|
||||
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<Boolean>()
|
||||
|
||||
slot.resolve(true)
|
||||
|
||||
assertFalse(slot.isPending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel drops the matching callback without invoking it`() {
|
||||
val slot = PendingCallback<Boolean>()
|
||||
val received = mutableListOf<Boolean>()
|
||||
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<Boolean>()
|
||||
val stale = mutableListOf<Boolean>()
|
||||
val current = mutableListOf<Boolean>()
|
||||
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<Boolean>()
|
||||
val outer = mutableListOf<Boolean>()
|
||||
val inner = mutableListOf<Boolean>()
|
||||
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<Boolean>()
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<RunIntentArbiter.Token> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<String>) = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-8
@@ -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,36 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
+55
-22
@@ -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,36 @@
|
||||
"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": "パスワードを非表示"
|
||||
}
|
||||
|
||||
+56
-23
@@ -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,36 @@
|
||||
"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": "Скрыть пароль"
|
||||
}
|
||||
|
||||
+42
-9
@@ -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,36 @@
|
||||
"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": "隐藏密码"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
Submodule core/Clash.Meta updated: 0f7f05adff...70f0570405
+19
-2
@@ -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);
|
||||
}
|
||||
if (result_func == NULL) {
|
||||
return;
|
||||
}
|
||||
result_func(invoke_Interface, data);
|
||||
}
|
||||
|
||||
+219
-126
@@ -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"
|
||||
)
|
||||
|
||||
var (
|
||||
currentConfig *config.Config
|
||||
version = 0
|
||||
isRunning = false
|
||||
runLock sync.Mutex
|
||||
mBatch, _ = batch.New[bool](context.Background(), batch.WithConcurrencyNum[bool](50))
|
||||
debugError = false
|
||||
const (
|
||||
delayTestConcurrency = 50
|
||||
defaultTestURL = "https://www.gstatic.com/generate_204"
|
||||
defaultDelayTestTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
func getExternalProvidersRaw() map[string]cp.Provider {
|
||||
var (
|
||||
configMu sync.Mutex
|
||||
currentConfig *config.Config
|
||||
|
||||
// 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") != ""
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return data, err
|
||||
constant.DefaultTestURL = url
|
||||
testURL.Store(&url)
|
||||
}
|
||||
|
||||
func updateConfig(params *UpdateParams) {
|
||||
runLock.Lock()
|
||||
defer runLock.Unlock()
|
||||
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
|
||||
}
|
||||
|
||||
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) 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 {
|
||||
currentConfig.Controller.ExternalController = *params.ExternalController
|
||||
route.ReCreateServer(&route.Config{
|
||||
Addr: currentConfig.Controller.ExternalController,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
patchTun(&general.Tun, params.Tun)
|
||||
}
|
||||
if params.ExternalController != nil &&
|
||||
*params.ExternalController != currentConfig.Controller.ExternalController {
|
||||
currentConfig.Controller.ExternalController = *params.ExternalController
|
||||
route.ReCreateServer(routeConfig(currentConfig))
|
||||
}
|
||||
|
||||
if params.GeoAutoUpdate != nil {
|
||||
updater.SetGeoAutoUpdate(*params.GeoAutoUpdate)
|
||||
}
|
||||
if params.GeoUpdateInterval != nil {
|
||||
updater.SetGeoUpdateInterval(*params.GeoUpdateInterval)
|
||||
}
|
||||
updateListeners(currentConfig)
|
||||
syncGeoUpdater(params.GeoAutoUpdate, params.GeoUpdateInterval)
|
||||
return nil
|
||||
}
|
||||
|
||||
updateListeners()
|
||||
func syncGeoUpdater(autoUpdate *bool, interval *int) {
|
||||
changed := false
|
||||
if autoUpdate != nil && *autoUpdate != updater.GeoAutoUpdate() {
|
||||
updater.SetGeoAutoUpdate(*autoUpdate)
|
||||
changed = true
|
||||
}
|
||||
if interval != nil && *interval != updater.GeoUpdateInterval() {
|
||||
updater.SetGeoUpdateInterval(*interval)
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
reconcileGeoUpdater()
|
||||
}
|
||||
|
||||
var (
|
||||
registerGeoUpdater = updater.RegisterGeoUpdaterWithCancel
|
||||
stopGeoUpdater = updater.StopGeoUpdater
|
||||
)
|
||||
|
||||
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...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+6
-10
@@ -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
|
||||
}
|
||||
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
|
||||
+2
-3
@@ -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)
|
||||
|
||||
+21
-23
@@ -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
|
||||
|
||||
+46
-48
@@ -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=
|
||||
|
||||
+402
-264
@@ -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()
|
||||
const globalProxyName = "GLOBAL"
|
||||
|
||||
nameList := config.GetProxyNameList()
|
||||
|
||||
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()
|
||||
statistic.DefaultManager.Range(func(c statistic.Tracker) bool {
|
||||
_ = c.Close()
|
||||
return true
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
func closeConnections() {
|
||||
statistic.DefaultManager.Range(func(c statistic.Tracker) bool {
|
||||
err := c.Close()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
fn("")
|
||||
}()
|
||||
const (
|
||||
geoUpdateScope = "geo:"
|
||||
providerUpdateScope = "provider:"
|
||||
)
|
||||
|
||||
var (
|
||||
updateMu sync.Mutex
|
||||
updateInFlight = map[string]bool{}
|
||||
geoHookClaims = map[string]bool{}
|
||||
)
|
||||
|
||||
func claimUpdate(key string) bool {
|
||||
updateMu.Lock()
|
||||
defer updateMu.Unlock()
|
||||
if updateInFlight[key] {
|
||||
return false
|
||||
}
|
||||
updateInFlight[key] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func handleSideLoadExternalProvider(providerName string, data []byte, fn func(value string)) {
|
||||
go func() {
|
||||
runLock.Lock()
|
||||
defer runLock.Unlock()
|
||||
externalProvider, exist := externalProviders[providerName]
|
||||
if !exist {
|
||||
fn("external provider is not exist")
|
||||
return
|
||||
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)
|
||||
}
|
||||
err := sideUpdateExternalProvider(externalProvider, data)
|
||||
if err != nil {
|
||||
fn(err.Error())
|
||||
return
|
||||
})
|
||||
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"
|
||||
}
|
||||
fn("")
|
||||
}()
|
||||
}
|
||||
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"
|
||||
}
|
||||
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)
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prof, err := config.UnmarshalRawConfig(bytes)
|
||||
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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+128
-88
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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])
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
//go:build cgo
|
||||
//go:build android && cgo
|
||||
|
||||
package main
|
||||
|
||||
|
||||
+107
-27
@@ -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)
|
||||
}
|
||||
|
||||
type messageClass int
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func sendMessage(message Message) {
|
||||
queue := priorityMessageQueue
|
||||
if message.Type == LogMessage || message.Type == RequestMessage {
|
||||
queue = bulkMessageQueue
|
||||
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:
|
||||
}
|
||||
enqueueLatest(queue, message)
|
||||
}
|
||||
|
||||
func enqueueLatest(queue chan Message, message Message) {
|
||||
select {
|
||||
case queue <- message:
|
||||
return
|
||||
default:
|
||||
}
|
||||
for attempt := 0; attempt < messageEvictAttempts; attempt++ {
|
||||
select {
|
||||
case queue <- message:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Event delivery must never block the core. Each priority class evicts only
|
||||
// its own oldest event, so log or request floods cannot displace state.
|
||||
select {
|
||||
case <-queue:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case queue <- message:
|
||||
default:
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
-185
@@ -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,6 +96,191 @@ func (response MethodResponse) notImplemented(method CoreMethod) {
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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())
|
||||
}),
|
||||
forceGcMethod: withoutArguments(func(response MethodResponse) {
|
||||
handleForceGC()
|
||||
response.success(true)
|
||||
}),
|
||||
shutdownMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleShutdown())
|
||||
}),
|
||||
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))
|
||||
}),
|
||||
getConfigMethod: withArguments(func(path *string, response MethodResponse) {
|
||||
rawConfig, err := handleGetConfig(*path)
|
||||
if err != nil {
|
||||
response.failure("core_error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
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))
|
||||
})
|
||||
}),
|
||||
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)
|
||||
}),
|
||||
asyncTestDelayMethod: withArguments(func(params *TestDelayParams, response MethodResponse) {
|
||||
safeGo(response, func() {
|
||||
response.success(handleTestDelay(params))
|
||||
})
|
||||
}),
|
||||
getConnectionsMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleGetConnections())
|
||||
}),
|
||||
closeConnectionsMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleCloseConnections())
|
||||
}),
|
||||
resetConnectionsMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleResetConnections())
|
||||
}),
|
||||
closeConnectionMethod: withArguments(func(id *string, response MethodResponse) {
|
||||
response.success(handleCloseConnection(*id))
|
||||
}),
|
||||
getExternalProvidersMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleGetExternalProviders())
|
||||
}),
|
||||
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("")
|
||||
})
|
||||
}),
|
||||
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("")
|
||||
})
|
||||
}),
|
||||
updateGeoDataMethod: withArguments(func(geoType *string, response MethodResponse) {
|
||||
response.success(handleUpdateGeoData(*geoType))
|
||||
}),
|
||||
startLogMethod: withoutArguments(func(response MethodResponse) {
|
||||
handleStartLog()
|
||||
response.success(true)
|
||||
}),
|
||||
stopLogMethod: withoutArguments(func(response MethodResponse) {
|
||||
handleStopLog()
|
||||
response.success(true)
|
||||
}),
|
||||
startListenerMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleStartListener())
|
||||
}),
|
||||
stopListenerMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleStopListener())
|
||||
}),
|
||||
getMemoryMethod: withoutArguments(func(response MethodResponse) {
|
||||
safeGo(response, func() {
|
||||
response.success(handleGetMemory())
|
||||
})
|
||||
}),
|
||||
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
|
||||
@@ -83,194 +291,15 @@ func handleMethodCall(call *MethodCall, response MethodResponse) {
|
||||
}
|
||||
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])
|
||||
logError("panic in handleMethodCall(%s): %v\n%s", call.Method, r, stackTrace())
|
||||
response.failure("internal_error", fmt.Sprintf("internal panic: %v", r), nil)
|
||||
}
|
||||
}()
|
||||
switch call.Method {
|
||||
case initClashMethod:
|
||||
params := InitParams{}
|
||||
if !decodeMethodArguments(call, response, ¶ms) {
|
||||
return
|
||||
}
|
||||
response.success(handleInitClash(¶ms))
|
||||
|
||||
handler, exists := methodHandlers[call.Method]
|
||||
if !exists {
|
||||
response.notImplemented(call.Method)
|
||||
return
|
||||
case getIsInitMethod:
|
||||
response.success(handleGetIsInit())
|
||||
return
|
||||
case forceGcMethod:
|
||||
handleForceGC()
|
||||
response.success(true)
|
||||
return
|
||||
case shutdownMethod:
|
||||
response.success(handleShutdown())
|
||||
return
|
||||
case validateConfigMethod:
|
||||
path := ""
|
||||
if !decodeMethodArguments(call, response, &path) {
|
||||
return
|
||||
}
|
||||
response.success(handleValidateConfig(path))
|
||||
return
|
||||
case updateConfigMethod:
|
||||
params := UpdateParams{}
|
||||
if !decodeMethodArguments(call, response, ¶ms) {
|
||||
return
|
||||
}
|
||||
response.success(handleUpdateConfig(¶ms))
|
||||
return
|
||||
case setupConfigMethod:
|
||||
params := defaultSetupParams()
|
||||
if !decodeMethodArguments(call, response, params) {
|
||||
return
|
||||
}
|
||||
response.success(handleSetupConfig(params))
|
||||
return
|
||||
case getProxiesMethod:
|
||||
response.success(handleGetProxies())
|
||||
return
|
||||
case changeProxyMethod:
|
||||
params := ChangeProxyParams{}
|
||||
if !decodeMethodArguments(call, response, ¶ms) {
|
||||
return
|
||||
}
|
||||
handleChangeProxy(¶ms, func(value string) {
|
||||
response.success(value)
|
||||
})
|
||||
return
|
||||
case getTrafficMethod:
|
||||
onlyStatisticsProxy := false
|
||||
if !decodeMethodArguments(call, response, &onlyStatisticsProxy) {
|
||||
return
|
||||
}
|
||||
response.success(handleGetTraffic(onlyStatisticsProxy))
|
||||
return
|
||||
case getTotalTrafficMethod:
|
||||
onlyStatisticsProxy := false
|
||||
if !decodeMethodArguments(call, response, &onlyStatisticsProxy) {
|
||||
return
|
||||
}
|
||||
response.success(handleGetTotalTraffic(onlyStatisticsProxy))
|
||||
return
|
||||
case resetTrafficMethod:
|
||||
handleResetTraffic()
|
||||
response.success(true)
|
||||
return
|
||||
case asyncTestDelayMethod:
|
||||
params := TestDelayParams{}
|
||||
if !decodeMethodArguments(call, response, ¶ms) {
|
||||
return
|
||||
}
|
||||
handleAsyncTestDelay(¶ms, func(value *Delay) {
|
||||
response.success(value)
|
||||
})
|
||||
return
|
||||
case getConnectionsMethod:
|
||||
response.success(handleGetConnections())
|
||||
return
|
||||
case closeConnectionsMethod:
|
||||
response.success(handleCloseConnections())
|
||||
return
|
||||
case resetConnectionsMethod:
|
||||
response.success(handleResetConnections())
|
||||
return
|
||||
case getConfigMethod:
|
||||
path := ""
|
||||
if !decodeMethodArguments(call, response, &path) {
|
||||
return
|
||||
}
|
||||
config, err := handleGetConfig(path)
|
||||
if err != nil {
|
||||
response.failure("core_error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.success(config)
|
||||
return
|
||||
case closeConnectionMethod:
|
||||
id := ""
|
||||
if !decodeMethodArguments(call, response, &id) {
|
||||
return
|
||||
}
|
||||
response.success(handleCloseConnection(id))
|
||||
return
|
||||
case getExternalProvidersMethod:
|
||||
response.success(handleGetExternalProviders())
|
||||
return
|
||||
case getExternalProviderMethod:
|
||||
externalProviderName := ""
|
||||
if !decodeMethodArguments(call, response, &externalProviderName) {
|
||||
return
|
||||
}
|
||||
response.success(handleGetExternalProvider(externalProviderName))
|
||||
return
|
||||
case updateGeoDataMethod:
|
||||
geoType := ""
|
||||
if !decodeMethodArguments(call, response, &geoType) {
|
||||
return
|
||||
}
|
||||
handleUpdateGeoData(geoType)
|
||||
response.success("")
|
||||
return
|
||||
case updateExternalProviderMethod:
|
||||
providerName := ""
|
||||
if !decodeMethodArguments(call, response, &providerName) {
|
||||
return
|
||||
}
|
||||
handleUpdateExternalProvider(providerName, func(value string) {
|
||||
response.success(value)
|
||||
})
|
||||
return
|
||||
case sideLoadExternalProviderMethod:
|
||||
params := map[string]string{}
|
||||
if !decodeMethodArguments(call, response, ¶ms) {
|
||||
return
|
||||
}
|
||||
providerName := params["providerName"]
|
||||
data := params["data"]
|
||||
handleSideLoadExternalProvider(providerName, []byte(data), func(value string) {
|
||||
response.success(value)
|
||||
})
|
||||
return
|
||||
case startLogMethod:
|
||||
handleStartLog()
|
||||
response.success(true)
|
||||
return
|
||||
case stopLogMethod:
|
||||
handleStopLog()
|
||||
response.success(true)
|
||||
return
|
||||
case startListenerMethod:
|
||||
response.success(handleStartListener())
|
||||
return
|
||||
case stopListenerMethod:
|
||||
response.success(handleStopListener())
|
||||
return
|
||||
case getCountryCodeMethod:
|
||||
ip := ""
|
||||
if !decodeMethodArguments(call, response, &ip) {
|
||||
return
|
||||
}
|
||||
handleGetCountryCode(ip, func(value string) {
|
||||
response.success(value)
|
||||
})
|
||||
return
|
||||
case getMemoryMethod:
|
||||
handleGetMemory(func(value uint64) {
|
||||
response.success(value)
|
||||
})
|
||||
return
|
||||
case clearEffectMethod:
|
||||
var profileId int64
|
||||
if !decodeMethodArguments(call, response, &profileId) {
|
||||
return
|
||||
}
|
||||
handleClearEffect(profileId, response)
|
||||
return
|
||||
default:
|
||||
if !handlePlatformMethodCall(call, response) {
|
||||
response.notImplemented(call.Method)
|
||||
}
|
||||
}
|
||||
handler(call, response)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !(darwin || linux) || android
|
||||
|
||||
package main
|
||||
|
||||
func initOwnership(homeDir string) {}
|
||||
|
||||
func scheduleReclaimOwnership() {}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
+40
-8
@@ -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
|
||||
}
|
||||
|
||||
+50
-21
@@ -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))
|
||||
if !bytes.EqualFold(local, column(row, netIndexOfLocal)) {
|
||||
continue
|
||||
}
|
||||
|
||||
local := fmt.Sprintf("%s:%s", hex.EncodeToString(nativeEndianIP(sIP)), hex.EncodeToString(bytes[:]))
|
||||
uidColumn := column(row, netIndexOfUid)
|
||||
if uidColumn == nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
for {
|
||||
row, _, err := reader.ReadLine()
|
||||
uid, err := strconv.Atoi(string(uidColumn))
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
fields := strings.Fields(string(row))
|
||||
|
||||
if len(fields) <= netIndexOfLocal || len(fields) <= netIndexOfUid {
|
||||
continue
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
+121
-53
@@ -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
|
||||
const (
|
||||
maxIPCFrameSize = 64 * 1024 * 1024
|
||||
ipcWriteTimeout = 10 * time.Second
|
||||
ipcPartialFrameRetries = 6
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
return writeAll(w, data)
|
||||
n, err := writeAll(w, data)
|
||||
return written + n, err
|
||||
}
|
||||
|
||||
func writeAll(w io.Writer, data []byte) error {
|
||||
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
|
||||
}
|
||||
writer.stalls++
|
||||
return writer.conn.SetWriteDeadline(time.Now().Add(ipcWriteTimeout)) == nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
connMu.Lock()
|
||||
defer connMu.Unlock()
|
||||
if err := writeFrame(conn, data); err != nil {
|
||||
logError("server write error: %v", err)
|
||||
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()
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+65
-64
@@ -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<void> Function(List<ConnectivityResult> 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<Application> {
|
||||
},
|
||||
);
|
||||
|
||||
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<Application> {
|
||||
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<Application> {
|
||||
),
|
||||
);
|
||||
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<void> _handleConnectivityChanged(
|
||||
List<ConnectivityResult> 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<Application> {
|
||||
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!,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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<ProviderContainer> 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<DynamicColorSeeds> _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<ProviderContainer> _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<void> attach() async {
|
||||
if (globalState.isAttach == true) {
|
||||
return;
|
||||
}
|
||||
await _initApp();
|
||||
globalState.isAttach = true;
|
||||
}
|
||||
|
||||
ProviderContainer get _container => globalState.container;
|
||||
|
||||
Future<void> _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<void> _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<void> _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<void> _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<void> _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();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:fl_clash/common/provider_reader.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
|
||||
abstract interface class WindowPort {
|
||||
Future<void> show();
|
||||
|
||||
Future<void> hide();
|
||||
|
||||
Future<void> close();
|
||||
|
||||
Future<bool> get isVisible;
|
||||
|
||||
void forceExit();
|
||||
}
|
||||
|
||||
abstract interface class TrayPort {
|
||||
Future<void> shutdown();
|
||||
|
||||
Future<void> update({
|
||||
required TrayState trayState,
|
||||
required Traffic traffic,
|
||||
required ProviderReader read,
|
||||
});
|
||||
}
|
||||
|
||||
abstract interface class NavigationPort {
|
||||
List<NavigationItem> getItems({bool openLogs, bool hasProxies});
|
||||
}
|
||||
|
||||
WindowPort? windowPort;
|
||||
TrayPort? trayPort;
|
||||
NavigationPort? navigationPort;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<BootRecord?> Function() _readRecord;
|
||||
final Future<void> Function(BootRecord record) _writeRecord;
|
||||
final Future<AppExitInfo?> Function() _readExitInfo;
|
||||
final Future<bool> Function() _readCrashReport;
|
||||
final int Function() _now;
|
||||
|
||||
BootDecision _decision = const BootDecision();
|
||||
|
||||
BootGuard({
|
||||
bool? supported,
|
||||
Future<BootRecord?> Function()? readRecord,
|
||||
Future<void> Function(BootRecord record)? writeRecord,
|
||||
Future<AppExitInfo?> Function()? readExitInfo,
|
||||
Future<bool> 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<BootDecision> 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<void> 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<void> 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();
|
||||
@@ -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<String, Object?> 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,
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,6 @@ extension CacheManagerExt on CacheManager {
|
||||
);
|
||||
}
|
||||
}
|
||||
streamController.close();
|
||||
unawaited(streamController.close());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = '<!-- flclash:changelog:json';
|
||||
|
||||
const _releaseChangelogJsonEndMarker = '-->';
|
||||
|
||||
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<String, dynamic>,
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
+8
-10
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -91,6 +91,10 @@ SelectedProxyState computeRealSelectedProxyState(
|
||||
);
|
||||
}
|
||||
|
||||
String delayTestKey(String testUrl, String proxyName) {
|
||||
return '$testUrl\u0000$proxyName';
|
||||
}
|
||||
|
||||
DelayState computeProxyDelayState({
|
||||
required String proxyName,
|
||||
required String testUrl,
|
||||
|
||||
+20
-33
@@ -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<NavigationItem>();
|
||||
const trackerInfoListEquality = ListEquality<TrackerInfo>();
|
||||
const stringListEquality = ListEquality<String>();
|
||||
const intListEquality = ListEquality<int>();
|
||||
const logListEquality = ListEquality<Log>();
|
||||
const groupListEquality = ListEquality<Group>();
|
||||
const ruleListEquality = ListEquality<Rule>();
|
||||
const scriptListEquality = ListEquality<Script>();
|
||||
const externalProviderListEquality = ListEquality<ExternalProvider>();
|
||||
const packageListEquality = ListEquality<Package>();
|
||||
const profileListEquality = ListEquality<Profile>();
|
||||
const proxyGroupsEquality = ListEquality<ProxyGroup>();
|
||||
const hotKeyActionListEquality = ListEquality<HotKeyAction>();
|
||||
const stringAndStringMapEquality = MapEquality<String, String>();
|
||||
const stringAndStringMapEntryListEquality =
|
||||
ListEquality<MapEntry<String, String>>();
|
||||
const stringAndStringMapEntryIterableEquality =
|
||||
IterableEquality<MapEntry<String, String>>();
|
||||
const stringAndObjectMapEntryIterableEquality =
|
||||
IterableEquality<MapEntry<String, Object?>>();
|
||||
const delayMapEquality = MapEquality<String, Map<String, int?>>();
|
||||
const stringSetEquality = SetEquality<String>();
|
||||
const keyboardModifierListEquality = SetEquality<KeyboardModifier>();
|
||||
|
||||
const viewModeColumnsMap = {
|
||||
ViewMode.mobile: [2, 1],
|
||||
ViewMode.laptop: [3, 2],
|
||||
ViewMode.desktop: [4, 3],
|
||||
};
|
||||
|
||||
const proxiesListStoreKey = PageStorageKey<String>('proxies_list');
|
||||
const toolsStoreKey = PageStorageKey<String>('tools');
|
||||
const profilesStoreKey = PageStorageKey<String>('profiles');
|
||||
@@ -127,9 +116,7 @@ double getWidgetHeight(num lines) {
|
||||
|
||||
const maxLength = 1000;
|
||||
|
||||
const mainIsolate = 'FlClashMainIsolate';
|
||||
|
||||
const serviceIsolate = 'FlClashServiceIsolate';
|
||||
const trafficSampleLength = 30;
|
||||
|
||||
const defaultPrimaryColors = [
|
||||
0xFF795548,
|
||||
|
||||
+19
-30
@@ -1,17 +1,27 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/manager/manager.dart';
|
||||
import 'package:fl_clash/manager/status_manager.dart';
|
||||
import 'package:fl_clash/models/state.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:fl_clash/widgets/scaffold.dart';
|
||||
import 'package:fl_clash/widgets/sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
extension BuildContextExtension on BuildContext {
|
||||
CommonScaffoldState? get commonScaffoldState {
|
||||
return findAncestorStateOfType<CommonScaffoldState>();
|
||||
}
|
||||
|
||||
bool get isMobileView {
|
||||
return ProviderScope.containerOf(
|
||||
this,
|
||||
listen: false,
|
||||
).read(isMobileViewProvider);
|
||||
}
|
||||
|
||||
void safeNestedPop<T extends Object?>([T? result]) {
|
||||
final nestedPop = SheetProvider.of(this)?.nestedNavigatorPop;
|
||||
if (nestedPop != null) {
|
||||
@@ -30,15 +40,20 @@ extension BuildContextExtension on BuildContext {
|
||||
}
|
||||
}
|
||||
|
||||
void showNotifier(String text, {MessageActionState? actionState}) {
|
||||
void showNotifier(
|
||||
String text, {
|
||||
MessageLevel level = MessageLevel.info,
|
||||
MessageActionState? actionState,
|
||||
}) {
|
||||
return findAncestorStateOfType<StatusManagerState>()?.message(
|
||||
text,
|
||||
level: level,
|
||||
actionState: actionState,
|
||||
);
|
||||
}
|
||||
|
||||
void showSnackBar(String message, {SnackBarAction? action}) {
|
||||
final width = viewWidth;
|
||||
final width = MediaQuery.sizeOf(this).width;
|
||||
EdgeInsets margin;
|
||||
if (width < 600) {
|
||||
margin = const EdgeInsets.only(bottom: 16, right: 16, left: 16);
|
||||
@@ -56,14 +71,6 @@ extension BuildContextExtension on BuildContext {
|
||||
);
|
||||
}
|
||||
|
||||
Size get appSize {
|
||||
return MediaQuery.of(this).size;
|
||||
}
|
||||
|
||||
double get viewWidth {
|
||||
return appSize.width;
|
||||
}
|
||||
|
||||
ColorScheme get colorScheme => Theme.of(this).colorScheme;
|
||||
|
||||
TextTheme get textTheme => Theme.of(this).textTheme;
|
||||
@@ -89,21 +96,3 @@ extension BuildContextExtension on BuildContext {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
class BackHandleInherited extends InheritedWidget {
|
||||
final Function handleBack;
|
||||
|
||||
const BackHandleInherited({
|
||||
super.key,
|
||||
required this.handleBack,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static BackHandleInherited? of(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<BackHandleInherited>();
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(BackHandleInherited oldWidget) {
|
||||
return handleBack != oldWidget.handleBack;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
|
||||
import 'print.dart';
|
||||
|
||||
T decodeOrRestoreDefault<T>(
|
||||
String label,
|
||||
T Function() decode,
|
||||
T Function() restoreDefault,
|
||||
) {
|
||||
try {
|
||||
return decode();
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'Discarded damaged $label and restored defaults: ${compactError(error)}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return restoreDefault();
|
||||
}
|
||||
}
|
||||
|
||||
class Uint8ListToListIntConverter extends Converter<Uint8List, List<int>> {
|
||||
@override
|
||||
List<int> convert(Uint8List input) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'context.dart';
|
||||
|
||||
@@ -16,6 +16,9 @@ extension DateTimeExtension on DateTime {
|
||||
|
||||
String getLastUpdateTimeDesc(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
if (year <= 1970) {
|
||||
return appLocalizations.unknown;
|
||||
}
|
||||
final currentDateTime = DateTime.now();
|
||||
final difference = currentDateTime.difference(this);
|
||||
final days = difference.inDays;
|
||||
@@ -53,3 +56,24 @@ extension DateTimeExtension on DateTime {
|
||||
return toString().substring(10, 19);
|
||||
}
|
||||
}
|
||||
|
||||
String getDateStringLast2(int value) {
|
||||
final valueRaw = '0$value';
|
||||
return valueRaw.substring(valueRaw.length - 2);
|
||||
}
|
||||
|
||||
String getTimeText(int? timeStamp) {
|
||||
if (timeStamp == null) {
|
||||
return '00:00:00';
|
||||
}
|
||||
final diff = timeStamp / 1000;
|
||||
final inHours = (diff / 3600).floor();
|
||||
if (inHours > 999) {
|
||||
return '999:59:59';
|
||||
}
|
||||
final inMinutes = (diff / 60 % 60).floor();
|
||||
final inSeconds = (diff % 60).floor();
|
||||
final hoursText = inHours.toString().padLeft(2, '0');
|
||||
|
||||
return '$hoursText:${getDateStringLast2(inMinutes)}:${getDateStringLast2(inSeconds)}';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:webdav_client/webdav_client.dart';
|
||||
@@ -24,7 +25,11 @@ class DAVClient {
|
||||
try {
|
||||
await client.ping();
|
||||
return true;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'dav ping error ${e.toString()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class Dialogs {
|
||||
Dialogs._();
|
||||
|
||||
BuildContext get _context => rootNavigatorKey.currentContext!;
|
||||
|
||||
Future<T?> showCommonDialog<T>({
|
||||
required Widget child,
|
||||
BuildContext? context,
|
||||
bool? dismissible,
|
||||
bool filter = true,
|
||||
}) async {
|
||||
return showModal<T>(
|
||||
useRootNavigator: false,
|
||||
context: context ?? _context,
|
||||
configuration: FadeScaleTransitionConfiguration(
|
||||
barrierColor: Colors.black38,
|
||||
barrierDismissible: dismissible ?? true,
|
||||
),
|
||||
builder: (_) => child,
|
||||
filter: filter ? commonFilter : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> showMessage({
|
||||
required InlineSpan message,
|
||||
BuildContext? context,
|
||||
String? title,
|
||||
String? confirmText,
|
||||
String? cancelText,
|
||||
bool cancelable = true,
|
||||
bool? dismissible,
|
||||
}) async {
|
||||
return showCommonDialog<bool>(
|
||||
context: context,
|
||||
dismissible: dismissible,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return CommonDialog(
|
||||
title: title ?? appLocalizations.tip,
|
||||
actions: [
|
||||
if (cancelable)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: Text(cancelText ?? appLocalizations.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: Text(confirmText ?? appLocalizations.confirm),
|
||||
),
|
||||
],
|
||||
child: Container(
|
||||
width: 300,
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText.rich(
|
||||
TextSpan(
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
children: [message],
|
||||
),
|
||||
style: const TextStyle(overflow: TextOverflow.visible),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> showAllUpdatingMessagesDialog(
|
||||
List<UpdatingMessage> messages,
|
||||
) async {
|
||||
return showCommonDialog<bool>(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return CommonDialog(
|
||||
backgroundColor: context.colorScheme.surfaceContainerLow,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
title: appLocalizations.tip,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: Text(appLocalizations.confirm),
|
||||
),
|
||||
],
|
||||
child: generateSectionV3(
|
||||
items: messages.map(
|
||||
(message) => _UpdatingMessageItem(message: message),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> showDisclaimer() async {
|
||||
return await showCommonDialog<bool>(
|
||||
dismissible: false,
|
||||
child: CommonDialog(
|
||||
title: currentAppLocalizations.disclaimer,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(_context).pop<bool>(false);
|
||||
},
|
||||
child: Text(currentAppLocalizations.exit),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(_context).pop<bool>(true);
|
||||
},
|
||||
child: Text(currentAppLocalizations.agree),
|
||||
),
|
||||
],
|
||||
child: Text(currentAppLocalizations.disclaimerDesc),
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
void showNotifier(
|
||||
String text, {
|
||||
MessageLevel level = MessageLevel.info,
|
||||
MessageActionState? actionState,
|
||||
}) {
|
||||
rootNavigatorKey.currentContext?.showNotifier(
|
||||
text,
|
||||
level: level,
|
||||
actionState: actionState,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> openUrl(String url) async {
|
||||
final res = await showMessage(
|
||||
message: TextSpan(text: url),
|
||||
title: currentAppLocalizations.externalLink,
|
||||
confirmText: currentAppLocalizations.go,
|
||||
);
|
||||
if (res != true) {
|
||||
return;
|
||||
}
|
||||
unawaited(launchUrl(Uri.parse(url)));
|
||||
}
|
||||
}
|
||||
|
||||
class _UpdatingMessageItem extends StatelessWidget {
|
||||
final UpdatingMessage message;
|
||||
|
||||
const _UpdatingMessageItem({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecorationListItem(
|
||||
minVerticalPadding: 12,
|
||||
title: TooltipText(
|
||||
text: Text(message.label, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
subtitle: TooltipText(
|
||||
text: Text(
|
||||
message.message,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final dialogs = Dialogs._();
|
||||
@@ -0,0 +1,8 @@
|
||||
final class MessageException implements Exception {
|
||||
final String message;
|
||||
|
||||
const MessageException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -36,3 +36,11 @@ extension FileSystemEntityExt on FileSystemEntity {
|
||||
await delete(recursive: recursive);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> safeDeletePath(String path) async {
|
||||
final entity = switch (FileSystemEntity.typeSync(path)) {
|
||||
FileSystemEntityType.directory => Directory(path),
|
||||
_ => File(path),
|
||||
};
|
||||
await entity.safeDelete(recursive: true);
|
||||
}
|
||||
|
||||
+27
-47
@@ -1,24 +1,38 @@
|
||||
import 'iterable.dart';
|
||||
|
||||
typedef ValueCallback<T> = T Function();
|
||||
|
||||
class FixedList<T> {
|
||||
final int maxLength;
|
||||
final List<T> _list;
|
||||
|
||||
final int _revision;
|
||||
List<T>? _snapshot;
|
||||
|
||||
FixedList(this.maxLength, {List<T>? list})
|
||||
: _list = (list ?? [])..truncate(maxLength);
|
||||
: assert(maxLength > 0, 'FixedList without a cap grows without bound'),
|
||||
_list = (list ?? [])..truncate(maxLength),
|
||||
_revision = 0;
|
||||
|
||||
FixedList._(this.maxLength, this._list, this._revision);
|
||||
|
||||
int get revision => _revision;
|
||||
|
||||
void add(T item) {
|
||||
_list.add(item);
|
||||
_list.truncate(maxLength);
|
||||
_snapshot = null;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_list.clear();
|
||||
_snapshot = null;
|
||||
}
|
||||
|
||||
List<T> get list => List.unmodifiable(_list);
|
||||
FixedList<T> append(T item) {
|
||||
add(item);
|
||||
return FixedList._(maxLength, _list, _revision + 1);
|
||||
}
|
||||
|
||||
List<T> get list => _snapshot ??= List.unmodifiable(_list);
|
||||
|
||||
int get length => _list.length;
|
||||
|
||||
@@ -27,47 +41,13 @@ class FixedList<T> {
|
||||
FixedList<T> copyWith() {
|
||||
return FixedList(maxLength, list: List.of(_list));
|
||||
}
|
||||
}
|
||||
|
||||
class FixedMap<K, V> {
|
||||
int maxLength;
|
||||
late Map<K, V> _map;
|
||||
|
||||
FixedMap(this.maxLength, {Map<K, V>? map}) {
|
||||
_map = map ?? {};
|
||||
}
|
||||
|
||||
V updateCacheValue(K key, ValueCallback<V> callback) {
|
||||
final realValue = _map.updateCacheValue(key, callback);
|
||||
_adjustMap();
|
||||
return realValue;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_map.clear();
|
||||
}
|
||||
|
||||
void updateMaxLength(int size) {
|
||||
maxLength = size;
|
||||
_adjustMap();
|
||||
}
|
||||
|
||||
void updateMap(Map<K, V> map) {
|
||||
_map = map;
|
||||
_adjustMap();
|
||||
}
|
||||
|
||||
void _adjustMap() {
|
||||
if (_map.length > maxLength) {
|
||||
_map = Map.fromEntries(map.entries.toList()..truncate(maxLength));
|
||||
}
|
||||
}
|
||||
|
||||
V? get(K key) => _map[key];
|
||||
|
||||
bool containsKey(K key) => _map.containsKey(key);
|
||||
|
||||
int get length => _map.length;
|
||||
|
||||
Map<K, V> get map => Map.unmodifiable(_map);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is FixedList<T> &&
|
||||
identical(other._list, _list) &&
|
||||
other._revision == _revision;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(identityHashCode(_list), _revision);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
void _applyAndReport(dynamic tag, Function func, List<dynamic>? args) {
|
||||
void report(Object error, StackTrace stackTrace) {
|
||||
commonPrint.log(
|
||||
'Scheduled task $tag failed: ${compactError(error)}, $stackTrace',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final result = Function.apply(func, args);
|
||||
if (result is Future) {
|
||||
result.then<void>((_) {}, onError: report);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
report(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
class Debouncer {
|
||||
final Map<dynamic, Timer?> _operations = {};
|
||||
final Map<dynamic, ({Function func, List<dynamic>? args})> _pending = {};
|
||||
|
||||
void call(
|
||||
dynamic tag,
|
||||
@@ -15,16 +36,32 @@ class Debouncer {
|
||||
if (timer != null) {
|
||||
timer.cancel();
|
||||
}
|
||||
_pending[tag] = (func: func, args: args);
|
||||
_operations[tag] = Timer(duration ?? const Duration(milliseconds: 600), () {
|
||||
_operations[tag]?.cancel();
|
||||
_operations.remove(tag);
|
||||
Function.apply(func, args);
|
||||
final pending = _pending.remove(tag);
|
||||
if (pending == null) {
|
||||
return;
|
||||
}
|
||||
_applyAndReport(tag, pending.func, pending.args);
|
||||
});
|
||||
}
|
||||
|
||||
void flush(dynamic tag) {
|
||||
final timer = _operations.remove(tag);
|
||||
timer?.cancel();
|
||||
final pending = _pending.remove(tag);
|
||||
if (timer == null || pending == null) {
|
||||
return;
|
||||
}
|
||||
_applyAndReport(tag, pending.func, pending.args);
|
||||
}
|
||||
|
||||
void cancel(dynamic tag) {
|
||||
_operations[tag]?.cancel();
|
||||
_operations[tag] = null;
|
||||
_pending.remove(tag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,14 +96,14 @@ class Throttler {
|
||||
return true;
|
||||
}
|
||||
if (fire) {
|
||||
Function.apply(func, args);
|
||||
_applyAndReport(tag, func, args);
|
||||
_operations[tag] = Timer(duration, () {
|
||||
_operations[tag]?.cancel();
|
||||
_operations.remove(tag);
|
||||
});
|
||||
} else {
|
||||
_operations[tag] = Timer(duration, () {
|
||||
Function.apply(func, args);
|
||||
_applyAndReport(tag, func, args);
|
||||
_operations[tag]?.cancel();
|
||||
_operations.remove(tag);
|
||||
});
|
||||
@@ -95,9 +132,25 @@ Future<T> retry<T>({
|
||||
}
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
throw 'retry error';
|
||||
throw TimeoutException('retry gave up after $maxAttempts attempts');
|
||||
}
|
||||
|
||||
final debouncer = Debouncer();
|
||||
|
||||
final throttler = Throttler();
|
||||
|
||||
FutureOr<T> handleWatch<T>({
|
||||
required Function function,
|
||||
required void Function() onStart,
|
||||
required void Function(T data, int elapsedMilliseconds) onEnd,
|
||||
}) async {
|
||||
if (kDebugMode && watchExecution) {
|
||||
onStart();
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final res = await function();
|
||||
stopwatch.stop();
|
||||
onEnd(res, stopwatch.elapsedMilliseconds);
|
||||
return res;
|
||||
}
|
||||
return await function();
|
||||
}
|
||||
|
||||
+15
-8
@@ -1,21 +1,28 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FlClashHttpOverrides extends HttpOverrides {
|
||||
static String handleFindProxy(Uri url) {
|
||||
final ProviderContainer _container;
|
||||
|
||||
FlClashHttpOverrides(this._container);
|
||||
|
||||
static String findProxyFor(ProviderContainer container, Uri url) {
|
||||
return findProxyForReader(container.read, url);
|
||||
}
|
||||
|
||||
static String findProxyForReader(ProviderReader read, Uri url) {
|
||||
if ([localhost].contains(url.host)) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
final ref = globalState.container;
|
||||
final isStart = ref.read(isStartProvider);
|
||||
final suspend = ref.read(suspendProvider);
|
||||
final isStart = read(isStartProvider);
|
||||
final suspend = read(suspendProvider);
|
||||
commonPrint.log('find $url proxy: $isStart');
|
||||
if (!isStart || suspend) return 'DIRECT';
|
||||
final mixedPort = ref.read(
|
||||
final mixedPort = read(
|
||||
patchClashConfigProvider.select((state) => state.mixedPort),
|
||||
);
|
||||
return 'PROXY localhost:$mixedPort';
|
||||
@@ -25,7 +32,7 @@ class FlClashHttpOverrides extends HttpOverrides {
|
||||
HttpClient createHttpClient(SecurityContext? context) {
|
||||
final client = super.createHttpClient(context);
|
||||
client.badCertificateCallback = (_, _, _) => true;
|
||||
client.findProxy = handleFindProxy;
|
||||
client.findProxy = (url) => findProxyFor(_container, url);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user