Compare commits
8 Commits
v0.8.97
...
v0.8.97-pre.4
| Author | SHA1 | Date | |
|---|---|---|---|
| f56a24da09 | |||
| fa64cedd77 | |||
| 51472a8c18 | |||
| 0b1d7b8b74 | |||
| bb20185dc4 | |||
| d556ca0306 | |||
| 1b35c2bf25 | |||
| 6053119030 |
@@ -4,7 +4,8 @@ Use the smallest durable surface that matches the job.
|
||||
|
||||
## Surfaces
|
||||
|
||||
- `AGENTS.md`: auto-loaded repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
|
||||
- `AGENTS.md`: repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
|
||||
- `CLAUDE.md`: the `@AGENTS.md` import and nothing that is not Claude-only. Claude Code loads `CLAUDE.md` into the system prompt at startup; the import makes `AGENTS.md` arrive with it. Without it, `AGENTS.md` reaches the session only as a tool result some agent chose to fetch, which is the weakest position in context and the first thing dropped when the conversation is summarized. Do not let rules accumulate in this file.
|
||||
- `.agents/*.md`: human- and agent-readable reference docs linked from `AGENTS.md`. Use these for detailed project context, commands, architecture, and conventions.
|
||||
- `.agents/skills/*/SKILL.md`: repo-scoped Codex skills. Use these for repeatable workflows that should be discoverable by name and description.
|
||||
- `.codex/config.toml`: trusted project Codex settings such as MCP, hooks, sandbox, approval, or model defaults.
|
||||
@@ -17,6 +18,9 @@ Use the smallest durable surface that matches the job.
|
||||
- Put detailed explanations in `.agents/*.md` and link them from `AGENTS.md`.
|
||||
- Put reusable task workflows in `.agents/skills/<skill-name>/SKILL.md`.
|
||||
- Put mechanical enforcement in linters, tests, hooks, or `.codex/rules`; do not rely on prose when tooling can enforce the rule.
|
||||
- Split a rule into the part that can be counted and the part that cannot, and enforce them differently. A rule resting entirely on judgment gets decided at the keyboard, at the moment the exception looks most justified; the comment policy is the worked example, where "is this comment useful" stays prose and review while "are these comments too frequent" became a measured gate. Find the countable half before writing more prose.
|
||||
- Restate a rule verbatim in every context that must obey it, and prefer a runnable check to a description of the rule. A gate an agent trips before reporting done outperforms a paragraph asking it to have been careful.
|
||||
- State a gate's real force. Enforcement differs per tool, and a rule that claims uniform coverage it does not have is worse than one that names the gap.
|
||||
- Keep user-specific preferences out of the repository. They belong in user-level Codex configuration or user-level skills.
|
||||
|
||||
## Skill Authoring Rules
|
||||
|
||||
@@ -16,7 +16,8 @@ Android lib mode:
|
||||
Desktop core mode:
|
||||
|
||||
- Go core runs as a separate process with `CGO_ENABLED=0`.
|
||||
- `rust_api` provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe on Windows.
|
||||
- `rust_api`'s `ipc` module provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe
|
||||
on Windows.
|
||||
Dart now owns the transport state, RPC correlation, process ownership, and lifecycle convergence above those primitives.
|
||||
- `lib/core/service.dart` (`CoreService`) is the composition root. It wires the IPC transport, launcher selection,
|
||||
lifecycle controller, RPC client, and crash-event bridge; it is no longer the whole desktop implementation by itself.
|
||||
@@ -26,8 +27,8 @@ Desktop core mode:
|
||||
applies a three-minute default method timeout, unwraps `CoreMethodResponse`, and fails all pending calls when transport
|
||||
disconnects or closes.
|
||||
- `lib/core/desktop/lifecycle.dart` serializes process intents and owns the authoritative desktop state machine.
|
||||
- `lib/core/desktop/launcher.dart` abstracts direct child-process and Windows Helper ownership through idempotent process
|
||||
leases. `lib/core/desktop/helper_client.dart` is the typed loopback HTTP client for the privileged Helper.
|
||||
- `lib/core/desktop/launcher.dart` abstracts direct child-process and privileged Helper ownership through idempotent process
|
||||
leases. `lib/core/desktop/helper_client.dart` is the typed local HTTP client for the privileged Helper.
|
||||
|
||||
`lib/core/controller.dart` (`CoreController`) selects the implementation based on platform. `lib/core/interface.dart` defines the shared `CoreHandlerInterface`.
|
||||
|
||||
@@ -39,6 +40,31 @@ Key Go core files:
|
||||
- `core/lib.go`: CGO exports.
|
||||
- `core/server.go`: desktop socket/named-pipe client and framed message forwarding.
|
||||
|
||||
## Listener Exposure
|
||||
|
||||
`_makeRealProfileTask` in `lib/common/task.dart` writes `mixed-port`, `allow-lan`, and `external-controller` into every
|
||||
generated profile. With `allow-lan` off, mihomo's `genAddr` binds each listener to `127.0.0.1`; the external controller is
|
||||
`ExternalControllerStatus.close` by default and never binds anywhere but loopback.
|
||||
|
||||
The loopback listener takes local connections without authentication by default: mandatory credentials would lock out
|
||||
every external local consumer a proxy client exists for. The binding only guarantees traffic originates on the device;
|
||||
it does not isolate programs already running there, so on Android any app holding `INTERNET` can use the proxy and learn
|
||||
the outbound IP (#1934). The opt-in answer is the local authentication setting (`NetworkProps.localAuth`), which spans
|
||||
four paths that must stay in sync: `_makeRealProfileTask` writes the credentials into `authentication` and force-clears
|
||||
`skip-auth-prefixes` so a profile cannot silently exempt loopback; `UpdateParams.authentication` applies the same list
|
||||
to a running core; `FlClashHttpOverrides` sends the credentials with the app's own proxied requests; and `sharedState`
|
||||
withholds the Android VPN system proxy declaration because `ProxyInfo` cannot carry credentials (TUN still captures that
|
||||
traffic). Desktop system proxy is deliberately not gated — dropping it would leak traffic direct, so the setting's
|
||||
description tells users to supply credentials manually instead.
|
||||
|
||||
### Interface Name Modes
|
||||
|
||||
The same task also writes `interface-name` per `PatchClashConfig.interfaceNameMode`: `clear` (default) forces it empty so a
|
||||
subscription's value cannot survive into the generated profile, `follow` leaves whatever the profile YAML already has, and
|
||||
`custom` writes the user's `interfaceName`. mihomo ignores `interface-name` on Android whenever `dialer.DefaultSocketHook`
|
||||
is installed (`core/lib.go` `installHooks`, vendored `dialer.go`), so `NetworkListView` in `lib/views/config/network.dart`
|
||||
shows the mode picker and text field only on desktop.
|
||||
|
||||
## Lifecycle Ownership And Convergence
|
||||
|
||||
### Shared Flutter Layer
|
||||
@@ -87,8 +113,8 @@ caller, and uses a three-second watchdog as an emergency application-exit path.
|
||||
- An unexpected disconnect or transport failure while running is converted to `DesktopCoreFailure`, the owned process is
|
||||
cleaned up, and `CoreService` emits a Core crash event for the normal UI recovery path.
|
||||
|
||||
Direct launch is used on macOS/Linux and as the Windows fallback when the privileged Helper is not ready. When the Helper
|
||||
is ready on Windows, the Helper owns the Core child and Dart owns it through a session-scoped lease.
|
||||
Direct launch is used on macOS, inside an AppImage, and as the Windows/Linux fallback when the privileged Helper is not
|
||||
ready. When the Helper is ready, it owns the Core child and Dart owns it through a session-scoped lease.
|
||||
|
||||
### Android Service Lifecycle
|
||||
|
||||
@@ -157,6 +183,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 +204,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 +373,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 +418,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
|
||||
|
||||
@@ -244,67 +455,68 @@ Shared:
|
||||
|
||||
## Build System
|
||||
|
||||
`setup.dart` is the release build orchestrator:
|
||||
`setup.dart` is the release build orchestrator: it writes `env.json` (`APP_ENV`), activates `flutter_distributor` from
|
||||
the `chen08209/flutter_distributor` fork pinned to a `v<version>-flclash.<n>` tag (cut a new tag there and bump
|
||||
`--git-ref` when the fork changes), and leaves the Core artifacts to the build hook.
|
||||
|
||||
1. Writes `env.json` (`APP_ENV`).
|
||||
2. Activates `flutter_distributor` for packaging.
|
||||
3. Relies on the platform build hook to build the required Core artifacts before
|
||||
the native application is linked.
|
||||
The Go core and the Rust helper are built by a Dart build hook. `plugins/setup/hook/build.dart` only constructs
|
||||
`CoreBuilder`, a `package:hooks` `Builder` in `plugins/setup/setup_hooks/`, the same shape `rust_api` uses. Flutter
|
||||
runs it for every platform build and for `flutter test`, once per target architecture. `CoreBuilder` turns the hook
|
||||
input into a `BuildRequest` (repository root, `Target`, Android toolchain) and hands it to `buildPlatform`. It lives in
|
||||
`setup_hooks` so `dart test` there can cover it; a test run of `plugins/setup` itself would execute the hook. Failures
|
||||
reach the runner as `BuildError` (Go or Cargo failed, no Core for the architecture) or `InfraError` (a toolchain could
|
||||
not start, Flutter passed no NDK compiler, the package is not at `plugins/setup`).
|
||||
|
||||
Go core building is handled by `build_tool`, a standalone Dart CLI in `plugins/setup/buildkit/build_tool/`.
|
||||
What the hook protocol forces:
|
||||
|
||||
Platform build hooks inside `flutter build` trigger `build_tool` automatically:
|
||||
- Android derives the per-ABI clang wrapper from the C compiler and `targetNdkApi` (the app's `minSdk`) Flutter
|
||||
passes; `hooks_runner` filters the environment, so `ANDROID_NDK` is not read.
|
||||
- `PATH` arrives unextended, so `runCommand` appends the Homebrew, Go and rustup locations that Xcode's and Gradle's
|
||||
stripped `PATH` hides.
|
||||
- The hook does not know the build mode: the protocol carries none and `linkingEnabled` only says whether link hooks
|
||||
run, so nothing in the hook branches on debug versus release. It cannot tell `flutter test` from a build either; the
|
||||
`build_assets: false` user-define in the root `pubspec.yaml` is the one switch (see `.agents/commands.md`), and
|
||||
`setup.dart` refuses to package while it is set.
|
||||
- The hook declares the files it read but the *directories* it wrote: `hooks_runner` hashes a file by content and a
|
||||
directory by child names, so `libclash/macos/` catches a deleted artifact without MD5-ing a 55 MB Core on every
|
||||
build. A rebuild touches those directories after the runner's cutoff, so the next build runs the hook once more and
|
||||
setup's cache answers it; that extra run is expected.
|
||||
- The runner discards hook output on success, even under `-v`, so the hook mirrors it into
|
||||
`.dart_tool/setup_build_cache/hook.log` (rotated at 512 KB), one `===` header per invocation.
|
||||
|
||||
- macOS: podspec script phase, `build_pod.sh`, `build_tool macos`.
|
||||
- Linux: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool linux`.
|
||||
- Windows: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool windows`. CMake forwards the active configuration through `BUILDKIT_CONFIGURATION`.
|
||||
- Android: Gradle include, `buildkit/gradle/plugin.gradle`, `build_tool android`.
|
||||
Platform projects copy the artifacts out of `libclash/`; application code must not import `plugins/setup`:
|
||||
|
||||
### Setup Build Harness Plugin
|
||||
- Android: the Go core is built `c-shared`, and `libclash.so` with its headers lands in the `:core` module (see Android
|
||||
Native Task Ordering).
|
||||
- macOS: a standalone `FlClashCore`. `Release.xcconfig` pins release and profile `ARCHS` to the host because
|
||||
flutter_tools otherwise builds a universal binary and every artifact ships one slice; the hook skips a non-host slice
|
||||
for the same reason. The `Stage Core` phase copies the Core after the hook may have rewritten it and fails when it is
|
||||
missing or lacks a slice for `ARCHS`, so a skipped hook cannot stage a stale Core silently.
|
||||
- Linux and Windows: `FlClashCore`, the Rust `FlClashHelperService`, and a `manifest.json` holding `coreSha256`; the
|
||||
Core builds first because the Helper embeds its hash. The CMake `install` rules copy them, and the Windows bundle
|
||||
places `manifest.json` beside the executable. A Helper running from a Debug build keeps its exe open and the install
|
||||
fails behind an opaque `MSB3073`, so `windows/CMakeLists.txt` stops it from an `install(CODE)` step for the `Debug`
|
||||
configuration only, and only when the staged Helper differs from the installed one; that step is where the
|
||||
configuration is known, and a Release install never stops a registered service. The step matches the process by
|
||||
executable path rather than image name, so a Helper registered from an installed release keeps running.
|
||||
|
||||
`plugins/setup/` is a build-time Flutter plugin, not a runtime Dart or FFI API. Its plugin shape exists so Flutter's native
|
||||
build graphs can run the Go/Rust build harness before platform consumers need the generated artifacts. Application code
|
||||
must not import or call it.
|
||||
Setup keeps its own cache under `.dart_tool/setup_build_cache/v1/` because it builds a Go core and, on Windows and
|
||||
Linux, a Rust helper:
|
||||
|
||||
Responsibilities are deliberately split:
|
||||
|
||||
- CocoaPods, Gradle, and CMake hooks schedule a lightweight check on every native build. They do not decide which Go or
|
||||
Rust files are stale.
|
||||
- `buildkit/build_tool/` owns target resolution, input fingerprinting, compilation, output copying, and cache validation.
|
||||
- `core/` and `services/helper/` remain source owners; `libclash/` and Android `jniLibs`/header directories are generated
|
||||
output locations.
|
||||
- `setup.dart` remains the release/package orchestrator and does not pre-build
|
||||
platform artifacts or use `dart-define` for Core integrity data. The Windows
|
||||
build tool writes the runtime `manifest.json` beside the Core output, and the
|
||||
Windows bundle copies it beside the application executable.
|
||||
|
||||
Platform outputs remain explicit:
|
||||
|
||||
- Android builds the Go core as `c-shared`, then copies `libclash.so` and generated headers into the `:core` Android module.
|
||||
- macOS and Linux build a standalone `FlClashCore` process used by the desktop socket integration.
|
||||
- Windows builds `FlClashCore.exe`, the Rust `FlClashHelperService.exe` privileged helper, and a
|
||||
`manifest.json` containing only `coreSha256`.
|
||||
|
||||
The hooks follow rust_api/Cargokit's phony-output scheduling pattern, but setup uses its own cache because it builds both a
|
||||
Go core and, on Windows, a separate Rust helper. Per-target records live under `.dart_tool/setup_build_cache/v1/`:
|
||||
|
||||
- Go fingerprints cover the target-specific `go list -deps` inputs inside `core/` and `Clash.Meta`, module files, effective
|
||||
build configuration, build-tool sources, target flags, Go environment/toolchain, and Android NDK compiler details.
|
||||
- Windows helper fingerprints cover its Rust sources and manifests, Cargo/Rust
|
||||
toolchains and flags, and the expected Core SHA256.
|
||||
- A cache hit requires the fingerprint and every recorded output's path, size, and modification state to match. It exits
|
||||
silently without Go/Cargo compilation, output copying, or Windows `taskkill`.
|
||||
- Cache records are written only after a successful build and protected by per-target process/file locks. Missing outputs,
|
||||
changed inputs, cache-schema changes, or `--force` rebuild only the affected target.
|
||||
- `flutter clean` removes `.dart_tool`, so the next native build performs one full core rebuild. Manual builds can bypass
|
||||
the cache with `make core-<platform> FORCE=1`.
|
||||
|
||||
This differs from `rust_api`: rust_api is a runtime Flutter Rust Bridge integration whose Cargokit hooks produce its native
|
||||
FFI library, while setup is only the build and packaging bridge for FlClash's external core artifacts.
|
||||
- Go fingerprints cover the target-specific `go list -deps` inputs in `core/` and `Clash.Meta`, module files, the
|
||||
effective build configuration, `setup_hooks` sources, target flags, the Go toolchain and the Android clang version.
|
||||
Helper fingerprints cover its Rust sources and manifests, Cargo/Rust toolchains and flags, and the expected Core
|
||||
SHA256.
|
||||
- A hit requires the fingerprint and every recorded output's path, size and modification state to match, and skips
|
||||
compilation and copying.
|
||||
- Records are written only after a successful build, under per-target locks. Compilation happens in a staging
|
||||
directory and moves into place on success, so a failed build never destroys the previous artifacts.
|
||||
- `flutter clean` removes `.dart_tool` and forces one full rebuild; deleting `.dart_tool/setup_build_cache/` does the
|
||||
same without a clean.
|
||||
|
||||
Windows helper integrity/version check:
|
||||
|
||||
- The build tool constructs the Core first, calculates its SHA256, and always
|
||||
- `setup_hooks` constructs the Core first, calculates its SHA256, and always
|
||||
builds the Rust Helper with release hardening and that expected hash.
|
||||
- Flutter reads the Core SHA256 from the bundled `manifest.json` and sends it with `/ping`. Debug, Profile, and Release
|
||||
builds use the same Helper protocol and may use TUN through the same flow.
|
||||
@@ -329,31 +541,110 @@ 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.
|
||||
- The Core must not outlive the Helper. On Linux the systemd unit's cgroup and `Restart=on-failure` cover that; on
|
||||
Windows the Helper puts the Core in a Job Object with `KILL_ON_JOB_CLOSE`, so the kernel kills the Core when the Helper
|
||||
process ends for any reason, and the `install` subcommand configures SCM failure actions (three restarts, 5 s apart,
|
||||
reset after 24 h). Stopping a Core sends SIGTERM first on Linux, because only a shutdown removes the policy routes
|
||||
sing-tun installed; the Core runs the same teardown itself when its IPC host disconnects or it receives a termination
|
||||
signal. `HelperClient.stopTimeout` must stay above the Helper's graceful-plus-kill budget.
|
||||
- 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`.
|
||||
Build configuration defaults live in `plugins/setup/setup_hooks/lib/src/options.dart` and can be overridden via the root
|
||||
`build_config.yaml`.
|
||||
|
||||
Architecture detection is automatic. The `--description` flag passed to `flutter_distributor` adds arch suffixes to artifact names, such as `FlClash-0.8.93-macos-arm64.dmg`.
|
||||
|
||||
#### Android Native Task Ordering
|
||||
|
||||
`:core` consumes `libclash.so` and its headers as files the setup build hook writes into `android/core/src/main`, not
|
||||
as an asset the hook hands back, so nothing in Gradle's model links the two. AGP's configure fingerprint tracks only
|
||||
`CMakeLists.txt` and its own generated files, which makes a wrong ordering *sticky*: a configure that ran before the
|
||||
hook stays cached and every later build reuses it. `CMakeLists.txt` therefore links `clash` unconditionally, and
|
||||
`android/core/build.gradle.kts` fails before any native task runs when those files are absent — both push the failure
|
||||
out of the configure step, where it would be cached, and into a place that re-evaluates every build. Three details
|
||||
there are easy to get wrong:
|
||||
|
||||
- `defaultConfig.ndk.abiFilters` is derived from the `target-platform` property Flutter passes to Gradle, mapped
|
||||
through the same ABI table as `Target.forPlatform('android')` in `setup_hooks`; keep the two tables equal. The hook
|
||||
builds a Core only for the platforms of the current build, so a `:core` ABI outside that set has no `libclash.so` to
|
||||
link, and without any filter AGP would configure the NDK's full default ABI set.
|
||||
- Match task names by **prefix**. AGP puts the ABI in the names of the tasks that do the work
|
||||
(`configureCMakeDebug[arm64-v8a]`). The bare `configureCMakeDebug`, `buildCMakeDebug`, and `externalNativeBuildDebug`
|
||||
that an exact-name match catches are grouping tasks that never enter an app build's execution graph.
|
||||
- Use `mustRunAfter`, not `dependsOn`. `:core` declares only `debug` and `release` variants, so a **profile** app
|
||||
consumes `:core`'s `debug` variant; no `:core` variant name maps onto the right `:app` task, and `dependsOn` would
|
||||
drag a second, wrong-mode Flutter build into the graph.
|
||||
|
||||
A standalone `./gradlew :core:assembleDebug` stays legal once the hook has run at least once; it only warns that the
|
||||
artifacts under `src/main` are whatever the last hook run left behind, which is the one failure mode this guard cannot
|
||||
detect. Verify a change here with `./gradlew ":core:buildCMakeDebug[arm64-v8a]" :app:compileFlutterBuildDebug
|
||||
--dry-run`: the Flutter task must be listed first even though the CMake task was requested first.
|
||||
|
||||
## Local Plugins
|
||||
|
||||
- `setup`: build-time harness for Go core artifacts and the Windows Rust helper; no runtime Dart API.
|
||||
- `setup`: build-time harness for Go core artifacts and the Rust helper, driven by a Dart build hook; 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.
|
||||
- `rust_api`: runtime Flutter Rust Bridge FFI package built through Native Assets. See below.
|
||||
- `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_api Crate Layout
|
||||
|
||||
`plugins/rust_api` has no platform folders. `hook/build.dart` is a Dart build hook: Flutter runs it for every platform
|
||||
build and for `flutter test`, and `flutter_rust_bridge_hooks` (over `native_toolchain_rust`) compiles the crate with
|
||||
Cargo and registers `librust_api` as a code asset that Flutter bundles and signs. The build runs
|
||||
`rustup run <channel>`, so `rust/rust-toolchain.toml` must pin an exact channel and list every target the project ships;
|
||||
the hook refuses `stable` and a target missing from that list. `rustup show` installs the pinned toolchain and those
|
||||
targets on first use.
|
||||
|
||||
`plugins/rust_api/rust/src/` separates the bridge boundary from the code behind it:
|
||||
|
||||
- `api/` is the only input flutter_rust_bridge parses (`rust_input: crate::api`). Every function there is a thin
|
||||
delegation, so the generated bindings stay identical on every platform.
|
||||
- `ipc/` implements the desktop socket server: `frame` (length-prefixed framing and the write backoff), `queue` (the
|
||||
bounded send queue), `platform` (socket cleanup, Windows peer credentials and the non-blocking pipe reader), and
|
||||
`server` (lifecycle, accept loop, and the `RUNNING`/`STATE` globals).
|
||||
- `script/` runs profile override scripts on QuickJS through `rquickjs`.
|
||||
- `hotkey/` registers desktop global shortcuts through `global-hotkey`: `keys` maps Flutter USB HID usages to key
|
||||
codes, `owner` runs every registration on the thread the platform binds it to (a dedicated message-loop thread on
|
||||
Windows, the main dispatch queue on macOS, in place on Linux), and `service` owns the registry and forwards presses
|
||||
to Dart. Linux is X11 only; a Wayland session without XWayland gets an error rather than a silent no-op.
|
||||
|
||||
What a platform does not use, it does not compile. `interprocess` and `global-hotkey` are declared under
|
||||
`cfg(not(target_os = "android"))`, and `ipc/mod.rs` and `hotkey/mod.rs` swap in their `unsupported.rs` there, because
|
||||
Android loads the Core in-process and has no global shortcuts. Adding a capability follows the same shape: implement it in its own module, gate the
|
||||
dependency by target, and keep the `api/` entry point unconditional so one set of bindings still serves every platform.
|
||||
|
||||
`RustLib.init()` runs on every platform now, not only desktop — the script engine is shared.
|
||||
|
||||
## Profile Script Engine
|
||||
|
||||
`lib/common/javascript.dart` sends the profile as JSON to `evaluate_script`, which runs `main(config)` on QuickJS and
|
||||
returns the JSON the script produced. Nothing about the script runs in Dart.
|
||||
|
||||
- QuickJS is compiled from source for the target being built, which is what removed the prebuilt `quickjs-c-bridge`
|
||||
binaries: `flutter_js` shipped x64 Windows and desktop-only libraries, so Windows ARM64 could not start (#2361).
|
||||
- `rquickjs` carries pre-generated bindings for every target this project builds except the Android ones, so Android
|
||||
builds enable its `bindgen` feature. That needs the NDK's own libclang and sysroot: `native_toolchain_rust` exports
|
||||
the sysroot through `BINDGEN_EXTRA_CLANG_ARGS_<target>`, and `hook/build.dart` adds `LIBCLANG_PATH` from the NDK
|
||||
toolchain Flutter hands the hook, because bindgen otherwise loads whatever libclang the host has, or none.
|
||||
- Evaluation is bounded: a 10-second interrupt deadline and a memory ceiling, because a script that never returns would
|
||||
otherwise hold the profile forever. `console` is installed before the script runs, since scripts written for other
|
||||
clients log as they work.
|
||||
- `rust/tests/fixtures/profile_script.js` is the compatibility regression: an overwrite written for the suite that
|
||||
performs the transform real ones perform, so it exercises `Map`/`Set`, spread, destructuring, optional chaining,
|
||||
nullish coalescing, `Object.fromEntries`, named capture groups and lookbehind in one pass. Keep it first-party and
|
||||
free of external URLs — vendoring somebody's published script here carries their attribution and their links.
|
||||
|
||||
## Rust Helper Service
|
||||
|
||||
`services/helper/` is a Windows-only privileged helper for starting the core as admin and managing TUN. It is built with:
|
||||
|
||||
```bash
|
||||
make core-windows
|
||||
```
|
||||
|
||||
The build tool always compiles the Helper in Rust release mode after calculating
|
||||
the SHA256 of the Core produced for the active Flutter configuration.
|
||||
`services/helper/` is the privileged helper that starts the core elevated so TUN works. It ships on Windows and Linux
|
||||
and is built by the setup build hook alongside the Core, which always compiles the Helper in Rust release mode
|
||||
after calculating the SHA256 of the Core produced for the active Flutter configuration.
|
||||
|
||||
The helper owns its Windows Service Control Manager lifecycle through two elevated commands:
|
||||
|
||||
@@ -365,6 +656,38 @@ The helper owns its Windows Service Control Manager lifecycle through two elevat
|
||||
The Dart layer only launches the helper's `install` command through `ShellExecuteW`; it does not compose `sc.exe`,
|
||||
`taskkill`, or `cmd.exe` command lines.
|
||||
|
||||
Linux takes the same shape with systemd in place of the Service Control Manager, and the same install timing: nothing
|
||||
is registered at package install, and `Linux.registerService` asks for elevation only when TUN authorization needs it.
|
||||
|
||||
- `FlClashHelperService install`, run through `pkexec` so polkit raises the system prompt, writes
|
||||
`/etc/systemd/system/flclash-helper.service` for the current executable path and enables and restarts it. It reads
|
||||
`PKEXEC_UID`/`SUDO_UID` to learn who asked, and refuses to install without one — there would be no account to grant
|
||||
the socket to. It also refuses a Helper whose binary or directory is not root-owned and non-writable (a unit runs it
|
||||
as root at every boot, so an unpacked bundle would be a standing escalation), and refuses to replace a unit already
|
||||
installed for a different UID rather than restart the service out from under that account.
|
||||
- That ownership check is why the `flutter_distributor` fork normalizes the packaging tree to 0755/0644 before
|
||||
`dpkg-deb`, `rpmbuild` and `appimagetool` run: they record modes verbatim, and Ubuntu's per-user default umask
|
||||
of 002 would otherwise ship `/opt/FlClash` as 0775, which the installer rejects as group-writable.
|
||||
- The rpm spec sets `debug_package` and `__os_install_post` to nil for the same reason: rpmbuild's find-debuginfo and
|
||||
brp-strip rewrite `FlClashCore`, and a Core whose SHA256 no longer matches the Helper's embedded value is refused at
|
||||
`/start`, which silently degrades every launch to the direct Core.
|
||||
- `FlClashHelperService uninstall` disables the unit, removes it and reloads systemd.
|
||||
- The unit carries `Group=` (the owner's primary GID), `RuntimeDirectory=flclash`, the owner's UID/GID in
|
||||
`FLCLASH_HELPER_OWNER_UID`/`_GID`, a double-quoted `ExecStart=` with `%` escaped, and `Restart=on-failure` under a
|
||||
start limit so a broken unit ends up failed instead of restarting forever. The helper serves
|
||||
`/run/flclash/helper.sock` at mode `0660`, additionally drops any connection whose `SO_PEERCRED` UID is not the
|
||||
owner's, logs and retries an `accept` failure instead of letting hyper end the server, and handles SIGTERM so
|
||||
`systemctl stop` still runs its own Core teardown.
|
||||
- `/start` additionally requires the Core address to be a socket owned by the owner UID before spawning, since the
|
||||
Core connects to it as root.
|
||||
- The Core is spawned with the owner's real UID and an effective UID of 0, which is what a setuid Core would have had.
|
||||
It is the signal `core/ownership_unix.go` uses to hand the files it created back to the user; without it a root
|
||||
service would leave a root-owned config tree in the owner's home.
|
||||
- An AppImage has neither a stable executable path nor a writable Core, and its FUSE mount is `nosuid`, so
|
||||
`system.isAppImage` reports TUN authorization as unavailable instead of prompting for a password that cannot help.
|
||||
- A Linux host without systemd (`/run/systemd/system` absent) has no Helper: `system.hasHelperService` is false there,
|
||||
readiness is the `stat` check, and `pkexec` sets the setuid bit on the bundled Core as before.
|
||||
|
||||
In every Flutter build mode `/start` opens the fixed Core executable beside the Helper without write/delete sharing,
|
||||
validates it against the SHA256 embedded only in the Helper, and keeps that handle open through process creation.
|
||||
`/ping` only compares the requested `coreSha256` with the Helper's embedded value and checks the fixed Core path exists;
|
||||
@@ -378,6 +701,9 @@ it never hashes the Core. Protocol version 6 uses 32-character lowercase-hex ses
|
||||
- `POST /stop` validates `{sessionId}` and only stops the matching managed Core. A session mismatch is HTTP 409.
|
||||
- `GET /logs` exposes the bounded recent Helper/Core stderr buffer with `no-store` caching.
|
||||
|
||||
All endpoints bind only to `127.0.0.1:47890` and do not use request-token authentication. Lifecycle safety comes from the
|
||||
fixed executable/hash, strict pipe namespace, session-scoped stop contract, and Dart-side peer-PID verification. When the
|
||||
Helper service itself shuts down, it unconditionally stops the Core process it owns.
|
||||
Endpoints bind only to `127.0.0.1:47890` on Windows and to `/run/flclash/helper.sock` on Linux, and do not use
|
||||
request-token authentication. Lifecycle safety comes from the fixed executable/hash, the strict address namespace
|
||||
(`\\.\pipe\FlClashCore_<32 hex>` on Windows, `/tmp/FlClashSocket_<digits>.sock` on Linux), the session-scoped stop
|
||||
contract, Dart-side peer-PID verification on Windows and, on Unix, the Core socket that `plugins/rust_api` sets to
|
||||
mode `0600` so only the owning user (and the root-effective Core) can connect. When the Helper service itself shuts
|
||||
down, it unconditionally stops the Core process it owns; under systemd the unit's control group does the same.
|
||||
|
||||
@@ -17,26 +17,45 @@ dart setup.dart windows
|
||||
dart setup.dart android
|
||||
```
|
||||
|
||||
Build only the Go core and skip Flutter packaging:
|
||||
The Go core and the Rust helper build automatically: Flutter runs
|
||||
`plugins/setup/hook/build.dart` on every `flutter build` and `flutter test`,
|
||||
and the hook drives `CoreBuilder` from the `setup_hooks` package in
|
||||
`plugins/setup/setup_hooks/`. Android builds take the NDK from the C compiler
|
||||
Flutter hands the hook; no `ANDROID_NDK` variable is needed.
|
||||
Artifacts land in `libclash/`. The hook reruns only when Go, Rust, or
|
||||
`setup_hooks` inputs change; the fingerprint cache lives in
|
||||
`.dart_tool/setup_build_cache/`. To force a rebuild, delete that directory:
|
||||
|
||||
```bash
|
||||
make core-macos
|
||||
make core-linux
|
||||
make core-windows
|
||||
make core-android
|
||||
rm -rf .dart_tool/setup_build_cache
|
||||
```
|
||||
|
||||
Pass `ARCH` or `TARGET_PLATFORM` through `make` when needed, for example:
|
||||
Flutter hides the hook's output on success, so to see why the Core was or was
|
||||
not rebuilt read `.dart_tool/setup_build_cache/hook.log`; each invocation starts
|
||||
with a `===` line carrying its timestamp and target.
|
||||
|
||||
```bash
|
||||
make core-macos ARCH=arm64
|
||||
make core-android TARGET_PLATFORM=android-arm64
|
||||
The hook runs for `flutter test` and for `dart run` of any root-package script
|
||||
too, so a pure Dart test run needs the Go toolchain, and on Linux and Windows
|
||||
also `cargo` and `rustc` — the Helper fingerprint shells out to both even on a
|
||||
cache hit. The hook protocol carries no build mode and no caller, so the only
|
||||
switch is the user-define in the root `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
user_defines:
|
||||
setup:
|
||||
build_assets: true # false turns the Go core and Helper hook into a no-op
|
||||
rust_api:
|
||||
build_assets: true # false does the same for the Rust library
|
||||
```
|
||||
|
||||
Core builds use setup's input fingerprint cache. Pass `FORCE=1` to bypass it,
|
||||
for example `make core-macos ARCH=arm64 FORCE=1`.
|
||||
|
||||
The Makefile wraps `plugins/setup/buildkit/run_build_tool.sh`; prefer the `make` entry points unless debugging the build tool itself.
|
||||
CI flips both to `false` with `yq` before `dart run tool/changelog.dart` and
|
||||
`flutter test`, and restores the file afterwards; no test loads either library.
|
||||
Never commit `false`: a build with it set stages whatever `libclash/` already
|
||||
holds and bundles no Rust library, which is why `setup.dart` refuses to package
|
||||
while it is set. Locally, `false` is worth setting for a Dart-only test loop,
|
||||
but the hook cache keys on the user-define, so the first build afterwards runs
|
||||
the hooks again.
|
||||
|
||||
## Flutter Development
|
||||
|
||||
@@ -71,6 +90,19 @@ Generated output paths, configured in `build.yaml`:
|
||||
- `lib/providers/generated/*.g.dart`.
|
||||
- `lib/database/generated/*.g.dart`.
|
||||
|
||||
Tray and Windows app icons are generated, not hand-edited. `assets_source/images/icon/*.svg` is
|
||||
the source of truth; the script needs `rsvg-convert` (librsvg) on `PATH`:
|
||||
|
||||
```bash
|
||||
dart run tool/generate_status_icons.dart
|
||||
```
|
||||
|
||||
It writes the tray PNGs with Flutter `2.0x/`–`4.0x/` resolution variants to `assets/images/tray/unix/`,
|
||||
multi-size tray `.ico` files to `assets/images/tray/windows/`, and `windows/runner/resources/app_icon.ico`
|
||||
from `assets/images/icon.png`. `pubspec.yaml` declares the two tray directories with `platforms:` so each
|
||||
build only bundles the format its tray loads; a new status icon needs a source SVG and an entry in the
|
||||
script's `statusIconNames`, nothing in `pubspec.yaml`.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use `package:test/test.dart` for pure Dart logic and `flutter_test` for provider and widget tests. `mocktail` is the mocking framework.
|
||||
@@ -87,7 +119,11 @@ 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`.
|
||||
`plugins/setup/setup_hooks/` is a pure Dart package and is tested with `dart test` from its own directory, which is
|
||||
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
|
||||
run of `plugins/setup` would execute the build hook itself.
|
||||
|
||||
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 +178,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 +245,10 @@ 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
|
||||
bash tool/check_comment_density_test.sh
|
||||
```
|
||||
|
||||
Run `flutter analyze` locally before committing when practical.
|
||||
@@ -160,3 +259,16 @@ 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.
|
||||
|
||||
## Worktree Tooling
|
||||
|
||||
```bash
|
||||
bash tool/worktrees.sh list # every worktree with owner tool and dirty/clean state
|
||||
bash tool/worktrees.sh prune # remove clean worktrees; add --force to drop dirty ones too
|
||||
```
|
||||
|
||||
@@ -4,22 +4,134 @@ 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
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
- GCC and Inno Setup.
|
||||
- `ANDROID_NDK` env var for Android builds.
|
||||
|
||||
macOS:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -23,55 +59,182 @@ Generated directories are excluded from analysis:
|
||||
|
||||
## Comments
|
||||
|
||||
Comments are opt-in and reserved for the few places that genuinely need one. Density is the point: every comment that
|
||||
restates the code devalues the comments that carry real information, until readers skim past all of them. A file with
|
||||
three comments that matter is more readable than one with thirty.
|
||||
Comments are part of the code and some of them are load-bearing. What this repository limits is the useless and the
|
||||
frequent. Every comment that restates the code devalues the ones carrying real information, until readers skim past all
|
||||
of them — a file with three comments that matter is more readable than one with thirty.
|
||||
|
||||
### Writing Comments
|
||||
The two halves are enforced differently, because only one of them can be counted. Whether a comment is useless is a
|
||||
judgment, and it lives in the rules below and in review. Whether comments are too frequent is arithmetic, and
|
||||
`comment-density` measures it.
|
||||
|
||||
- Never add a comment on your own initiative. This covers explanatory, narrative, TODO, section-divider, and
|
||||
documentation comments, in Dart, Kotlin, Swift, Go, Rust, YAML, Gradle, and any other file you touch.
|
||||
- Never annotate line by line or statement by statement, and never restate in prose what the code already says. If a
|
||||
block needs a comment per step, the block needs better names or a smaller decomposition instead.
|
||||
- When a change genuinely cannot be understood without a comment, do not write it silently. Explain what is unclear,
|
||||
propose the exact comment text, and wait for the user to approve it before adding it.
|
||||
### What a Comment Must Earn
|
||||
|
||||
- Write one when it carries something the code cannot: a non-obvious constraint, an upstream or platform behavior being
|
||||
worked around, a reason the next reader would otherwise get wrong, a coupling to a value defined somewhere else.
|
||||
- Never restate what the code already says. If the comment and the line below it convey the same thing, the comment is
|
||||
noise.
|
||||
- Never narrate. Not what this diff changed, not what the code used to be, not the order you did things in. Version
|
||||
history belongs in git and change rationale belongs in the commit message.
|
||||
- Never annotate line by line or statement by statement. A block that seems to need a comment per step needs better
|
||||
names or a smaller decomposition instead — reach for structure before prose.
|
||||
- Prefer encoding the intent in structure and naming. A named mixin, type, or method that makes the invariant hard to
|
||||
break beats a paragraph asking the next reader not to break it.
|
||||
- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file
|
||||
that contains them. This does not need approval.
|
||||
- These are not comments and must be preserved: analyzer and linter directives (`// ignore:`, `// ignore_for_file:`,
|
||||
`// coverage:ignore`), license and copyright headers, code-generation markers, and comments inside vendored upstream
|
||||
code such as `lib/widgets/open_container.dart`.
|
||||
- These are not comments and are exempt from every rule here, including the density count: analyzer and linter
|
||||
directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,
|
||||
code-generation markers, and comments inside vendored upstream code such as `lib/widgets/open_container.dart`.
|
||||
|
||||
### Density
|
||||
|
||||
`tool/check_comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring
|
||||
diffs under 20 added lines so small edits are never caught. The number is calibrated on this repository's own history:
|
||||
healthy changes sit at or under 3.6%, while the core fix that prompted the gate ran 22.4%.
|
||||
|
||||
It is a ceiling on frequency, not a target to fill. Do not read `core/`'s inherited mihomo density as a quota either —
|
||||
it is forked upstream code, not a house style.
|
||||
|
||||
Three gates run the same script, and they do not have equal force. The `PostToolUse` hooks in `.claude/settings.json`
|
||||
and `.codex/config.toml` run after the tool and hand offending lines back as feedback; neither can undo the completed
|
||||
write. The `comment-density` pre-commit hook fails the commit, and it is the only hard gate that covers every tool.
|
||||
Behavior is pinned by `tool/check_comment_density_test.sh`, which CI runs directly.
|
||||
|
||||
When a change genuinely warrants more, raise the ceiling for that run rather than working around it:
|
||||
`COMMENT_DENSITY_MAX=20 git commit`, or `SKIP=comment-density git commit` to step past it entirely.
|
||||
|
||||
### Where Knowledge Belongs
|
||||
|
||||
Pick the destination by where the constraint would be violated, not by how important it feels.
|
||||
A comment is one of three destinations, and often the weakest. Pick by where the constraint would be violated, not by
|
||||
how important it feels.
|
||||
|
||||
- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior
|
||||
it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.
|
||||
- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They
|
||||
are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do
|
||||
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
|
||||
constraint on the same screen.
|
||||
- **A fact that is true only at one call site, and is not visible from that call site, is the one thing a comment does
|
||||
better than a test or a document.** Its value is being in the reader's line of sight at the moment of the edit.
|
||||
`lib/common/constant.dart` carries one: 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. This is the case a
|
||||
comment is for; the bar is that no test and no `.agents/` entry could hold the fact instead.
|
||||
|
||||
Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;
|
||||
leaving a repo-wide policy as a comment reaches only the reader of that one file.
|
||||
|
||||
Before any of the three, prefer encoding the intent in structure and naming — a named mixin, type, or method that makes
|
||||
the invariant hard to break beats prose that asks the next reader not to break it.
|
||||
|
||||
## 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.
|
||||
- The desktop IPC socket in `plugins/rust_api` admits a Unix peer only when its effective uid is the app's own or root:
|
||||
the socket lives in `/tmp`, and the Core connects as root whenever it runs setuid for TUN or under the Linux Helper.
|
||||
A launch mode that runs the Core as any other user has to widen `authorize_peer` in `ipc/platform.rs`, and Windows
|
||||
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
|
||||
- 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`/`resolveUid`/`resolvePackage`/`onResult` becomes a
|
||||
crash in unrelated code. For the same reason every one of those wrappers checks its `tun_interface`/`invoke_interface`
|
||||
for `nullptr` first: ART aborts on a call through a null object, and a callback released by `TunHandler.clear` while a
|
||||
connection is still resolving is exactly that.
|
||||
- The Android bridge resolves an owner in two steps — `resolve_uid` then `resolve_package` — because mihomo fills
|
||||
`metadata.Uid` from a procfs lookup that Android Q closed off. Collapsing them back into one call that returns only a
|
||||
package name is what left every connection reporting uid 0, so `UID` rules matched nothing.
|
||||
- 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 +247,155 @@ 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`.
|
||||
- Reading the Wi-Fi SSID is opt-in work, not ambient state. `ConnectivityManager` reads it only while `excludeSSIDs` is
|
||||
non-empty, because that list is its only consumer through `suspendProvider`, and the read costs a blocking platform
|
||||
call plus a location permission on Android and macOS. A second consumer must widen that gate, not drop it.
|
||||
- On Android the `wifi_ssid` permission is only `granted` once `ACCESS_BACKGROUND_LOCATION` is held too (Q+). Fine
|
||||
location alone reads the SSID in the foreground but returns `UNKNOWN_SSID` once the app is backgrounded, which is
|
||||
where on-demand spends most of its time. `getSsid` itself only needs fine location, so the split stays inside the
|
||||
plugin.
|
||||
- No `wifi_ssid` implementation may answer `getSsid` on the platform thread. Android resolves through a network callback
|
||||
with a timeout, Linux through `g_task_run_in_thread`, and macOS through `ssidQueue` after checking the location
|
||||
authorization, because CoreWLAN reaches `wifid` over XPC and a wedged daemon would freeze the window. Windows is the
|
||||
outstanding exception: `WlanQueryInterface` still runs inline, since replying off-thread there needs a window-message
|
||||
hop that the plugin API does not provide.
|
||||
- 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 +407,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 +465,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.
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Use when changing FlClash UI text, scanning for hardcoded Chinese s
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for localization work inside `/Users/Shared/follow/FlClash`, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values.
|
||||
Use this for localization work in this repository, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values.
|
||||
|
||||
Do not use this for README translation sync or manual edits to generated localization Dart.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Worktrees
|
||||
|
||||
Claude Code, Codex, and Gemini each create git worktrees. `tool/worktrees.sh list` shows every worktree with its
|
||||
owning tool and whether it holds uncommitted work; `tool/worktrees.sh prune` removes the clean ones and keeps
|
||||
dirty ones unless `--force` is passed. `.claude/worktrees/` is ignored by git; `.claude/settings.json` is tracked.
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: implementer
|
||||
description: Use for scoped feature work, bug fixes, refactors, and focused test updates after the requirements are clear.
|
||||
tools: Read, Grep, Glob, Bash, Edit, Write
|
||||
model: sonnet
|
||||
effort: high
|
||||
---
|
||||
|
||||
Implement the requested change in the smallest appropriate scope. Follow the repository instructions in AGENTS.md and
|
||||
the
|
||||
linked .agents documentation, preserve existing lifecycle and serialization contracts, and keep unrelated working-tree
|
||||
changes untouched. Trace the affected call path before editing, update focused tests when behavior changes, and report
|
||||
the
|
||||
files changed plus the verification actually run.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: researcher
|
||||
description: Use for fast read-only codebase exploration, call-path tracing, and locating relevant tests before implementation.
|
||||
tools: Read, Grep, Glob
|
||||
model: sonnet
|
||||
effort: medium
|
||||
---
|
||||
|
||||
Trace the actual call path instead of stopping at the first matching file. Identify the relevant symbols, lifecycle owner,
|
||||
cross-platform boundary, existing tests, and focused verification commands. Do not modify files. Return concise,
|
||||
evidence-based findings with file paths and line references when available.
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
name: reviewer
|
||||
description: Use after code changes for correctness, regression, lifecycle, contract, and test review.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
model: opus
|
||||
effort: high
|
||||
---
|
||||
|
||||
Review the current changes without editing files. Inspect the diff, trace the relevant call paths, and check platform
|
||||
ownership, serialization or IPC contracts, error handling, and test coverage. Run focused read-only checks when useful.
|
||||
Report actionable findings first, ordered by severity, with file paths and line references; state explicitly when no issue is
|
||||
found and note any validation or platform coverage gaps.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PROJECT_DIR:-.}/tool/check_comment_density.sh\"",
|
||||
"timeout": 15,
|
||||
"statusMessage": "Checking comment density"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
name = "implementer"
|
||||
description = "Use for scoped feature work, bug fixes, refactors, and focused test updates after the requirements are clear."
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "workspace-write"
|
||||
developer_instructions = """
|
||||
Implement the requested change in the smallest appropriate scope. Follow the repository
|
||||
instructions in AGENTS.md and the linked .agents documentation. Preserve existing
|
||||
lifecycle and serialization contracts, and keep unrelated working-tree changes
|
||||
untouched. Trace the affected call path before editing, update focused tests when
|
||||
behavior changes, and report the files changed plus the verification actually run.
|
||||
"""
|
||||
@@ -1,11 +0,0 @@
|
||||
name = "researcher"
|
||||
description = "Use for fast read-only codebase exploration, call-path tracing, and locating relevant tests before implementation."
|
||||
model = "gpt-5.6-luna"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Trace the actual call path instead of stopping at the first matching file. Identify
|
||||
the relevant symbols, lifecycle owner, cross-platform boundary, existing tests, and
|
||||
focused verification commands. Do not modify files. Return concise, evidence-based
|
||||
findings with file paths and line references when available.
|
||||
"""
|
||||
@@ -1,13 +0,0 @@
|
||||
name = "reviewer"
|
||||
description = "Use after code changes for correctness, regression, lifecycle, contract, and test review."
|
||||
model = "gpt-5.6-luna"
|
||||
model_reasoning_effort = "max"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Review the current changes without editing files. Inspect the diff, trace the relevant
|
||||
call paths, and check platform ownership, serialization or IPC contracts, error
|
||||
handling, and test coverage. Run focused read-only checks when useful. Report
|
||||
actionable findings first, ordered by severity, with file paths and line references;
|
||||
state explicitly when no issue is found and note any validation or platform coverage
|
||||
gaps.
|
||||
"""
|
||||
@@ -1,5 +1,8 @@
|
||||
[agents]
|
||||
enabled = true
|
||||
max_concurrent_threads_per_session = 3
|
||||
default_subagent_model = "gpt-5.6-luna"
|
||||
default_subagent_reasoning_effort = "high"
|
||||
[[hooks.PostToolUse]]
|
||||
matcher = "apply_patch|Bash"
|
||||
|
||||
[[hooks.PostToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "bash tool/check_comment_density.sh"
|
||||
timeout = 15
|
||||
statusMessage = "Checking comment density"
|
||||
|
||||
@@ -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"
|
||||
@@ -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,15 @@ 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: Test comment density hook
|
||||
shell: bash
|
||||
run: bash tool/check_comment_density_test.sh
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
@@ -32,37 +44,35 @@ 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: Skip native build hooks
|
||||
run: |
|
||||
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false' pubspec.yaml
|
||||
|
||||
- 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: Restore native build hooks
|
||||
run: git checkout -- pubspec.yaml
|
||||
|
||||
- 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
|
||||
run: bash tool/check_plugins.sh
|
||||
|
||||
- name: Run local plugin tests
|
||||
shell: bash
|
||||
run: |
|
||||
(
|
||||
cd plugins/proxy
|
||||
flutter test --reporter expanded
|
||||
)
|
||||
(
|
||||
cd plugins/wifi_ssid
|
||||
flutter test --reporter expanded
|
||||
)
|
||||
|
||||
- name: Validate setup build tool
|
||||
working-directory: plugins/setup/buildkit/build_tool
|
||||
- name: Validate setup hooks package
|
||||
working-directory: plugins/setup/setup_hooks
|
||||
run: |
|
||||
dart pub get
|
||||
dart analyze
|
||||
@@ -76,11 +86,81 @@ 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
|
||||
# config at the repository root so the two cannot drift.
|
||||
tags="$(sed -n 's/^tags: *//p' ../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
|
||||
env:
|
||||
# The runner image exports ANDROID_NDK_HOME as r27, which Flutter resolves
|
||||
# first and which dropped libclang; the rust_api hook needs r28 for bindgen.
|
||||
ANDROID_NDK_HOME: ${{ steps.core-ndk.outputs.ndk-path }}
|
||||
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 +224,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Android Signing
|
||||
if: startsWith(matrix.platform,'android')
|
||||
@@ -183,6 +264,7 @@ jobs:
|
||||
- name: Setup NDK
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: build-ndk
|
||||
with:
|
||||
ndk-version: r28c
|
||||
add-to-path: true
|
||||
@@ -194,6 +276,8 @@ jobs:
|
||||
flutter pub get
|
||||
|
||||
- name: Setup
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.build-ndk.outputs.ndk-path }}
|
||||
run: dart setup.dart ${{ matrix.platform }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }} -v
|
||||
|
||||
- name: Upload
|
||||
@@ -203,61 +287,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 +313,35 @@ jobs:
|
||||
pattern: artifact-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate release.md
|
||||
shell: bash
|
||||
- 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: Skip native build hooks
|
||||
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
|
||||
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false' pubspec.yaml
|
||||
|
||||
- name: Generate release notes
|
||||
shell: bash
|
||||
env:
|
||||
IS_STABLE: ${{ env.IS_STABLE }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
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 }}
|
||||
|
||||
@@ -43,6 +43,7 @@ app.*.map.json
|
||||
|
||||
# Claude Code - ignore user-specific settings only
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Documentation (generated/planning artifacts)
|
||||
docs/
|
||||
@@ -68,8 +69,8 @@ docs/
|
||||
/macos/build/
|
||||
/env.json
|
||||
/core_sha256.json
|
||||
/core/*.exe
|
||||
/plugins/rust_api/.claude/
|
||||
/issue-triage/
|
||||
devtools_options.yaml
|
||||
|
||||
# FVM Version Cache
|
||||
|
||||
@@ -6,9 +6,21 @@ repos:
|
||||
entry: dart format
|
||||
language: system
|
||||
files: \.dart$
|
||||
stages: [pre-commit]
|
||||
- id: comment-density
|
||||
name: comment density
|
||||
entry: tool/check_comment_density.sh
|
||||
language: system
|
||||
files: \.(dart|kt|kts|swift|go|rs|java|cpp|cc|h|hpp|gradle|ya?ml)$
|
||||
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]
|
||||
|
||||
@@ -17,6 +17,7 @@ Read these only when the task touches their area:
|
||||
local plugins.
|
||||
- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,
|
||||
command rules, and hooks.
|
||||
- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.
|
||||
- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.
|
||||
|
||||
## Highest Priority Rules
|
||||
@@ -24,12 +25,15 @@ Read these only when the task touches their area:
|
||||
- When the user explicitly requests a scoped, low-risk change, inspect the relevant context and implement it directly.
|
||||
Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation.
|
||||
Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result.
|
||||
- Do not add code or configuration comments unless the user explicitly asks for comments. This includes explanatory,
|
||||
narrative, TODO, and documentation comments. Never annotate line by line; comments belong only at the few key points
|
||||
that cannot be understood without one, and there you must propose the exact text and wait for approval. Delete
|
||||
commented-out code and stale notes whenever you touch the surrounding code. Put assertable behavior in a test,
|
||||
repository-wide invariants in `.agents/`, and keep a comment only for a fact that is local to one call site.
|
||||
See [.agents/rules.md](.agents/rules.md) for the full policy.
|
||||
- Comments are allowed and sometimes necessary; excess is the problem. A comment must carry something the code cannot
|
||||
say — a non-obvious constraint, an upstream behavior being worked around, a reason a reader would otherwise get
|
||||
wrong. Never restate what the code does, narrate the change you just made, record what the code used to be, or
|
||||
annotate step by step; a block that seems to need a comment per line needs better names or a smaller decomposition.
|
||||
Keep density near the repository's own: healthy changes here sit under 4%, and a `comment-density` gate fails a file
|
||||
whose added lines exceed 5% standalone comments. Delete commented-out code and stale notes in files you already
|
||||
touch. Preserve
|
||||
`// ignore:`-style directives, license headers, codegen markers, and vendored upstream comments. See
|
||||
[.agents/rules.md](.agents/rules.md) for what belongs in a test or in `.agents/` instead.
|
||||
- Use `flutter test`, not `dart test`, because models pull in Flutter types.
|
||||
- Run code generation after modifying models, providers, or database schema.
|
||||
- Do not manually edit generated files.
|
||||
@@ -39,7 +43,9 @@ 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.
|
||||
- Follow `analysis_options.yaml`, especially single quotes, trailing commas, `child:` last, no `print()`, const/final
|
||||
- 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 `lint_options.yaml` (included by every `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
|
||||
`flutter test --reporter expanded` when practical.
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This repository uses [AGENTS.md](AGENTS.md) as the canonical agent entry point.
|
||||
Read that file first, then follow the `.agents/` references it routes to.
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,35 +1,12 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
PLATFORM ?= macos
|
||||
BUILDKIT := plugins/setup/buildkit/run_build_tool.sh
|
||||
ARCH_ARG := $(if $(ARCH),--arch $(ARCH),)
|
||||
TARGET_PLATFORM_ARG := $(if $(TARGET_PLATFORM),--target-platform $(TARGET_PLATFORM),)
|
||||
FORCE_ARG := $(if $(filter 1 true yes,$(FORCE)),--force,)
|
||||
|
||||
.PHONY: help submodules core core-macos core-linux core-windows core-android
|
||||
.PHONY: help submodules
|
||||
|
||||
help:
|
||||
@echo 'make core # build macOS core by default'
|
||||
@echo 'make core PLATFORM=linux ARCH=amd64'
|
||||
@echo 'make core-macos ARCH=arm64'
|
||||
@echo 'make core-android ARCH=arm64'
|
||||
@echo 'make core-android TARGET_PLATFORM=android-arm64'
|
||||
@echo 'make core-macos FORCE=1 # bypass setup build cache'
|
||||
@echo 'make submodules # update git submodules (Clash.Meta core sources)'
|
||||
@echo ''
|
||||
@echo 'The Go core and Rust helper build automatically through the setup build'
|
||||
@echo 'hook during flutter build; see .agents/commands.md.'
|
||||
|
||||
submodules:
|
||||
git submodule update --init --recursive
|
||||
|
||||
core:
|
||||
bash $(BUILDKIT) $(PLATFORM) $(ARCH_ARG) $(TARGET_PLATFORM_ARG) $(FORCE_ARG)
|
||||
|
||||
core-macos:
|
||||
$(MAKE) core PLATFORM=macos
|
||||
|
||||
core-linux:
|
||||
$(MAKE) core PLATFORM=linux
|
||||
|
||||
core-windows:
|
||||
$(MAKE) core PLATFORM=windows
|
||||
|
||||
core-android:
|
||||
$(MAKE) core PLATFORM=android
|
||||
|
||||
@@ -42,7 +42,6 @@ on Mobile:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
sudo apt-get install libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
### Android
|
||||
@@ -109,7 +108,7 @@ brew install --cask flclash
|
||||
|
||||
2. Dependencies are auto-installed by setup script, or manually:
|
||||
```bash
|
||||
sudo apt-get install -y libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
sudo apt-get install -y libayatana-appindicator3-dev
|
||||
```
|
||||
|
||||
3. Run build script
|
||||
|
||||
@@ -42,7 +42,6 @@ on Mobile:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
sudo apt-get install libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
### Android
|
||||
@@ -109,7 +108,7 @@ brew install --cask flclash
|
||||
|
||||
2. 依赖会由 setup 脚本自动安装,也可以手动安装:
|
||||
```bash
|
||||
sudo apt-get install -y libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
sudo apt-get install -y libayatana-appindicator3-dev
|
||||
```
|
||||
|
||||
3. 运行构建脚本
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="com.android.permission.GET_INSTALLED_APPS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@ object ServiceController {
|
||||
}
|
||||
|
||||
if (binding?.component != nextIntent.component) {
|
||||
clearBinding()
|
||||
if (binding != null) {
|
||||
tearDownServices()
|
||||
}
|
||||
lateinit var nextBinding: ManagedServiceBinding
|
||||
nextBinding = ManagedServiceBinding(nextIntent) { message ->
|
||||
handleServiceDisconnected(nextBinding, message)
|
||||
@@ -106,12 +108,29 @@ object ServiceController {
|
||||
}
|
||||
|
||||
suspend fun stop() = lock.withLock {
|
||||
tearDownServices()
|
||||
runTimeMillis = 0L
|
||||
}
|
||||
|
||||
private suspend fun tearDownServices() {
|
||||
binding?.useService { service -> service.stop() }
|
||||
?.onFailure { error ->
|
||||
GlobalState.log("Unable to stop background service: $error")
|
||||
}
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
stopServices()
|
||||
}
|
||||
|
||||
// A service the system started itself — always-on VPN, or the sticky restart
|
||||
// after the process was killed — outlives every binding this process holds, so
|
||||
// unbinding alone leaves the tunnel up while the app reports it stopped.
|
||||
private fun stopServices() {
|
||||
listOf(VpnService::class.intent, ProxyService::class.intent).forEach { intent ->
|
||||
runCatching { GlobalState.application.stopService(intent) }
|
||||
.onFailure { error ->
|
||||
GlobalState.log("Unable to stop ${intent.component?.className}: $error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isVpnServiceActive(): Boolean = lock.withLock {
|
||||
|
||||
@@ -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,133 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
internal interface TileGateway {
|
||||
fun handleStart()
|
||||
|
||||
fun handleStop()
|
||||
}
|
||||
|
||||
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,343 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.common.RunIntentArbiter
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
enum class RunState {
|
||||
STARTED,
|
||||
STARTING,
|
||||
STOPPING,
|
||||
STOPPED,
|
||||
}
|
||||
|
||||
internal typealias RunRequest = RunIntentArbiter.Token
|
||||
|
||||
internal const val MISSING_CONFIG_MESSAGE = "No configuration found."
|
||||
internal const val INVALID_CONFIG_MESSAGE = "Invalid configuration."
|
||||
internal const val VPN_PERMISSION_MESSAGE = "VPN permission required."
|
||||
internal const val START_FAILED_MESSAGE = "Failed to start service."
|
||||
|
||||
/**
|
||||
* 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,
|
||||
showStopAction = state.showStopAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ class TileService : android.service.quicksettings.TileService() {
|
||||
}
|
||||
}
|
||||
|
||||
// Always route through QuickActionActivity: startActivityAndCollapse is
|
||||
// what closes the shade, so toggling the service in place would leave
|
||||
// the panel open after a tap.
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
openQuickAction()
|
||||
|
||||
@@ -10,6 +10,7 @@ data class SharedState(
|
||||
val currentProfileName: String = "FlClash",
|
||||
val stopText: String = "Stop",
|
||||
val onlyStatisticsProxy: Boolean = false,
|
||||
val showStopAction: Boolean = true,
|
||||
val vpnOptions: VpnOptions? = null,
|
||||
val setupParams: SetupParams? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.follow.clash.packages
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
fun isSkipped(packageName: String): Boolean = SKIPPED_PREFIXES.any {
|
||||
packageName == it || packageName.startsWith("$it.")
|
||||
}
|
||||
|
||||
fun matchesKnownPrefix(name: String): Boolean = name.matches(CHINA_PACKAGE_REGEX)
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -14,8 +14,26 @@ internal class PackageResolver(
|
||||
private val packageManager: PackageManager,
|
||||
private val appPackageName: String,
|
||||
) {
|
||||
val installedPackages: List<InstalledPackage> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
loadPackages()
|
||||
private val cacheLock = Any()
|
||||
|
||||
@Volatile
|
||||
private var cachedPackages: List<InstalledPackage>? = null
|
||||
|
||||
val installedPackages: List<InstalledPackage>
|
||||
get() = cachedPackages ?: synchronized(cacheLock) {
|
||||
cachedPackages ?: loadPackages().also { cachedPackages = it }
|
||||
}
|
||||
|
||||
val isInstalledAppsPermissionSupported: Boolean by lazy {
|
||||
runCatching { packageManager.getPermissionInfo(GET_INSTALLED_APPS, 0) }.isSuccess
|
||||
}
|
||||
|
||||
fun hasInstalledAppsPermission(): Boolean = !isInstalledAppsPermissionSupported ||
|
||||
packageManager.checkPermission(GET_INSTALLED_APPS, appPackageName) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun invalidate() {
|
||||
synchronized(cacheLock) { cachedPackages = null }
|
||||
}
|
||||
|
||||
fun getChinaPackageNames(): List<String> = installedPackages
|
||||
@@ -52,16 +70,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,75 +118,22 @@ 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ANDROID_PACKAGE_NAME = "android"
|
||||
const val MAX_DEX_SIZE_BYTES = 15_000_000L
|
||||
companion object {
|
||||
const val GET_INSTALLED_APPS = "com.android.permission.GET_INSTALLED_APPS"
|
||||
|
||||
val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
|
||||
private const val ANDROID_PACKAGE_NAME = "android"
|
||||
private const val MAX_DEX_SIZE_BYTES = 15_000_000L
|
||||
|
||||
private val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
|
||||
PackageManager.GET_SERVICES or
|
||||
PackageManager.GET_RECEIVERS or
|
||||
PackageManager.GET_PROVIDERS
|
||||
|
||||
val SKIPPED_PREFIXES = listOf(
|
||||
"com.google",
|
||||
"com.android.chrome",
|
||||
"com.android.vending",
|
||||
"com.microsoft",
|
||||
"com.apple",
|
||||
"com.zhiliaoapp.musically",
|
||||
)
|
||||
|
||||
val CHINA_PACKAGE_REGEX = listOf(
|
||||
"com.tencent",
|
||||
"com.alibaba",
|
||||
"com.umeng",
|
||||
"com.qihoo",
|
||||
"com.ali",
|
||||
"com.alipay",
|
||||
"com.amap",
|
||||
"com.sina",
|
||||
"com.weibo",
|
||||
"com.vivo",
|
||||
"com.xiaomi",
|
||||
"com.huawei",
|
||||
"com.taobao",
|
||||
"com.secneo",
|
||||
"s.h.e.l.l",
|
||||
"com.stub",
|
||||
"com.kiwisec",
|
||||
"com.secshell",
|
||||
"com.wrapper",
|
||||
"cn.securitystack",
|
||||
"com.mogosec",
|
||||
"com.secoen",
|
||||
"com.netease",
|
||||
"com.mx",
|
||||
"com.qq.e",
|
||||
"com.baidu",
|
||||
"com.bytedance",
|
||||
"com.bugly",
|
||||
"com.miui",
|
||||
"com.oppo",
|
||||
"com.coloros",
|
||||
"com.iqoo",
|
||||
"com.meizu",
|
||||
"com.gionee",
|
||||
"cn.nubia",
|
||||
"com.oplus",
|
||||
"andes.oplus",
|
||||
"com.unionpay",
|
||||
"cn.wps",
|
||||
).joinToString("|", prefix = "(", postfix = ").*") { prefix ->
|
||||
Regex.escape(prefix)
|
||||
}.toRegex()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
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,8 +25,10 @@ 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.common.registerReceiverCompat
|
||||
import com.follow.clash.getPackageIconPath
|
||||
import com.follow.clash.packages.PackageResolver
|
||||
import com.follow.clash.showToast
|
||||
@@ -32,6 +39,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 +50,23 @@ 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 val requestInstalledAppsCallback = PendingCallback<Boolean>()
|
||||
|
||||
private var isRequestingNotificationPermission = false
|
||||
|
||||
@@ -61,9 +79,47 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
)
|
||||
}
|
||||
|
||||
private var packageChangeContext: Context? = null
|
||||
|
||||
private val packageChangeReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent == null || intent.data?.schemeSpecificPart == GlobalState.application.packageName) {
|
||||
return
|
||||
}
|
||||
val addedByUpdate = intent.action == Intent.ACTION_PACKAGE_ADDED &&
|
||||
intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
|
||||
if (addedByUpdate) {
|
||||
return
|
||||
}
|
||||
packageResolver.invalidate()
|
||||
channel.invokeMethod("packagesChanged", null)
|
||||
}
|
||||
}
|
||||
|
||||
private var skipNotificationPermissionRequest = false
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@@ -86,16 +142,20 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
}
|
||||
|
||||
"getPackages" -> {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
result.success(gson.toJson(packageResolver.installedPackages))
|
||||
}
|
||||
"getPackages" -> reply(result) {
|
||||
gson.toJson(packageResolver.installedPackages)
|
||||
}
|
||||
|
||||
"getChinaPackageNames" -> {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
result.success(gson.toJson(packageResolver.getChinaPackageNames()))
|
||||
}
|
||||
"getChinaPackageNames" -> reply(result) {
|
||||
gson.toJson(packageResolver.getChinaPackageNames())
|
||||
}
|
||||
|
||||
"isInstalledAppsPermissionGranted" -> reply(result) {
|
||||
packageResolver.hasInstalledAppsPermission()
|
||||
}
|
||||
|
||||
"requestInstalledAppsPermission" -> {
|
||||
requestInstalledAppsPermission { granted -> result.success(granted) }
|
||||
}
|
||||
|
||||
"getPackageIcon" -> {
|
||||
@@ -120,8 +180,12 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
result.success(openAppSettings())
|
||||
}
|
||||
|
||||
"didCrashOnPreviousExecution" -> {
|
||||
result.success(GlobalState.didCrashOnPreviousExecution())
|
||||
"didCrashOnPreviousExecution" -> reply(result) {
|
||||
GlobalState.didCrashOnPreviousExecution()
|
||||
}
|
||||
|
||||
"getLastExitInfo" -> reply(result) {
|
||||
GlobalState.lastExitInfo()
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -130,15 +194,21 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGetPackageIcon(call: MethodCall, result: Result) {
|
||||
scope.launch {
|
||||
val packageName = call.argument<String>("packageName")
|
||||
if (packageName == null) {
|
||||
result.success("")
|
||||
return@launch
|
||||
private fun handleGetPackageIcon(call: MethodCall, result: Result) = reply(result) {
|
||||
val packageName = call.argument<String>("packageName") ?: ""
|
||||
GlobalState.application.packageManager.getPackageIconPath(packageName)
|
||||
}
|
||||
|
||||
// A throw inside the plugin scope has no handler and ends the process;
|
||||
// the Dart caller gets a PlatformException to handle instead.
|
||||
private fun reply(result: Result, block: suspend () -> Any?) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
result.success(block())
|
||||
} catch (error: Exception) {
|
||||
GlobalState.log("Platform call failed: $error")
|
||||
result.error("PLATFORM_ERROR", error.toString(), null)
|
||||
}
|
||||
val path = GlobalState.application.packageManager.getPackageIconPath(packageName)
|
||||
result.success(path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,9 +277,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 +286,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 +299,46 @@ 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
|
||||
private fun requestInstalledAppsPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestInstalledAppsCallback.replace(callback, supersededValue = false)
|
||||
val activity = activity
|
||||
if (packageResolver.hasInstalledAppsPermission()) {
|
||||
invokeRequestInstalledAppsCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
if (activity == null) {
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
return@onMainThread
|
||||
}
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(PackageResolver.GET_INSTALLED_APPS),
|
||||
INSTALLED_APPS_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun invokeRequestInstalledAppsCallback(granted: Boolean) {
|
||||
if (granted) {
|
||||
packageResolver.invalidate()
|
||||
}
|
||||
requestInstalledAppsCallback.resolve(granted)
|
||||
}
|
||||
|
||||
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 +349,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) {
|
||||
@@ -278,13 +369,28 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
channel =
|
||||
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
|
||||
channel.setMethodCallHandler(this)
|
||||
watchPackageChanges(flutterPluginBinding.applicationContext)
|
||||
}
|
||||
|
||||
private fun watchPackageChanges(context: Context) {
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
context.registerReceiverCompat(packageChangeReceiver, filter)
|
||||
packageChangeContext = context
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
packageChangeContext?.unregisterReceiver(packageChangeReceiver)
|
||||
packageChangeContext = null
|
||||
channel.setMethodCallHandler(null)
|
||||
scope.cancel()
|
||||
invokeVpnPrepareCallback(false)
|
||||
invokeRequestNotificationCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
@@ -292,13 +398,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,9 +423,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
channel.invokeMethod("exit", null)
|
||||
activity = null
|
||||
detachFromActivity()
|
||||
invokeVpnPrepareCallback(false)
|
||||
invokeRequestNotificationCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
}
|
||||
|
||||
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
@@ -324,17 +441,27 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray,
|
||||
): Boolean {
|
||||
if (requestCode != NOTIFICATION_PERMISSION_REQUEST_CODE) {
|
||||
return false
|
||||
): Boolean = when (requestCode) {
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE -> {
|
||||
skipNotificationPermissionRequest = true
|
||||
invokeRequestNotificationCallback(true)
|
||||
true
|
||||
}
|
||||
skipNotificationPermissionRequest = true
|
||||
invokeRequestNotificationCallback(true)
|
||||
return true
|
||||
|
||||
INSTALLED_APPS_PERMISSION_REQUEST_CODE -> {
|
||||
invokeRequestInstalledAppsCallback(
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val VPN_PERMISSION_REQUEST_CODE = 1001
|
||||
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
|
||||
const val INSTALLED_APPS_PERMISSION_REQUEST_CODE = 1003
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,32 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
fun release(onRelease: () -> Unit = {}): Boolean {
|
||||
if (!released.compareAndSet(false, true)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
onRelease()
|
||||
} finally {
|
||||
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,53 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
/**
|
||||
* 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,28 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
@@ -4,6 +4,15 @@ plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
// Must equal Target.forPlatform("android") in setup_hooks, which writes libclash.so only for the platforms being built.
|
||||
val coreAbiByPlatform =
|
||||
linkedMapOf("android-arm" to "armeabi-v7a", "android-arm64" to "arm64-v8a", "android-x64" to "x86_64")
|
||||
val coreAbis =
|
||||
(rootProject.findProperty("target-platform") as String?)
|
||||
?.split(",")
|
||||
?.map { coreAbiByPlatform[it.trim()] ?: throw GradleException("No Core for Flutter target platform $it") }
|
||||
?: coreAbiByPlatform.values.toList()
|
||||
|
||||
android {
|
||||
namespace = "com.follow.clash.core"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
@@ -11,6 +20,10 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
|
||||
ndk {
|
||||
abiFilters += coreAbis
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
@@ -36,3 +49,39 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(libs.annotation.jvm)
|
||||
}
|
||||
|
||||
// Why prefix matching and mustRunAfter: .agents/architecture.md, "Android Native Task Ordering".
|
||||
val nativeTaskPattern =
|
||||
Regex("^(configureCMake|buildCMake|externalNativeBuild|merge.*(NativeLibs|JniLibFolders)|copy.*JniLibs)")
|
||||
val flutterCompileTasks =
|
||||
rootProject.project(":app").tasks.matching { it.name.startsWith("compileFlutterBuild") }
|
||||
|
||||
tasks.matching { nativeTaskPattern.containsMatchIn(it.name) }.configureEach {
|
||||
mustRunAfter(flutterCompileTasks)
|
||||
}
|
||||
|
||||
gradle.taskGraph.whenReady {
|
||||
val nativeTask =
|
||||
allTasks.firstOrNull {
|
||||
it.path.startsWith("${project.path}:") && nativeTaskPattern.containsMatchIn(it.name)
|
||||
}
|
||||
if (nativeTask == null || allTasks.any { it.path.startsWith(":app:compileFlutterBuild") }) {
|
||||
return@whenReady
|
||||
}
|
||||
val missing =
|
||||
coreAbis
|
||||
.flatMap {
|
||||
listOf(file("src/main/jniLibs/$it/libclash.so"), file("src/main/cpp/includes/$it/libclash.h"))
|
||||
}.filterNot { it.isFile }
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"${nativeTask.name} consumes Core artifacts that the setup build hook writes into src/main, " +
|
||||
"and these are absent: ${missing.joinToString { it.relativeTo(projectDir).path }}. " +
|
||||
"Run `dart setup.dart android` once, or build through :app so the hook runs first.",
|
||||
)
|
||||
}
|
||||
logger.warn(
|
||||
":core: ${nativeTask.name} is running without an :app Flutter compile task, " +
|
||||
"so the Core artifacts under src/main are whatever the last hook run left behind",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,26 +13,16 @@ if (NOT "${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
|
||||
add_link_options(-flto -Wl,--gc-sections,--strip-all)
|
||||
endif ()
|
||||
|
||||
set(LIB_CLASH_PATH "${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libclash.so")
|
||||
set(LIB_CLASH_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/../cpp/includes/${ANDROID_ABI}")
|
||||
set(LIB_CLASH_HEADER_PATH "${LIB_CLASH_INCLUDE_DIR}/libclash.h")
|
||||
set(BRIDE_HEADER_PATH "${LIB_CLASH_INCLUDE_DIR}/bride.h")
|
||||
|
||||
message("LIB_CLASH_PATH ${LIB_CLASH_PATH}")
|
||||
if (EXISTS ${LIB_CLASH_PATH} AND EXISTS ${LIB_CLASH_HEADER_PATH} AND EXISTS ${BRIDE_HEADER_PATH})
|
||||
message("Found libclash.so and headers for ABI ${ANDROID_ABI}")
|
||||
add_compile_definitions(LIBCLASH)
|
||||
include_directories(${LIB_CLASH_INCLUDE_DIR})
|
||||
link_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
jni_helper.cpp
|
||||
core.cpp)
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||
clash)
|
||||
else ()
|
||||
message("Not found complete libclash output for ABI ${ANDROID_ABI}; building core without LIBCLASH")
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
jni_helper.cpp
|
||||
core.cpp)
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME})
|
||||
endif ()
|
||||
# Never make linking libclash conditional on the file existing: AGP's configure
|
||||
# fingerprint tracks only CMakeLists.txt, so a configure that ran before the build
|
||||
# hook would stay cached and silently drop the Core. Failing at compile or link
|
||||
# time is what keeps that decision out of the configure step.
|
||||
include_directories(${LIB_CLASH_INCLUDE_DIR})
|
||||
link_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
jni_helper.cpp
|
||||
core.cpp)
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||
clash)
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
#include <jni.h>
|
||||
|
||||
#ifdef LIBCLASH
|
||||
#include <cstring>
|
||||
|
||||
#include "jni_helper.h"
|
||||
#include "libclash.h"
|
||||
#include "bride.h"
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
|
||||
jstring stack, jstring address, jstring dns) {
|
||||
const auto interface = new_global(cb);
|
||||
startTUN(interface, fd, get_string(stack), get_string(address), get_string(dns));
|
||||
return startTUN(interface, fd, get_string(stack), get_string(address), get_string(dns))
|
||||
? JNI_TRUE
|
||||
: JNI_FALSE;
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -82,7 +84,8 @@ Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring in
|
||||
|
||||
|
||||
static jmethodID m_tun_interface_protect;
|
||||
static jmethodID m_tun_interface_resolve_process;
|
||||
static jmethodID m_tun_interface_resolve_uid;
|
||||
static jmethodID m_tun_interface_resolve_package;
|
||||
static jmethodID m_invoke_interface_result;
|
||||
|
||||
|
||||
@@ -95,30 +98,56 @@ static void free_string_impl(char *str) {
|
||||
free(str);
|
||||
}
|
||||
|
||||
static void call_tun_interface_protect_impl(void *tun_interface, const int fd) {
|
||||
static int call_tun_interface_protect_impl(void *tun_interface, const int fd) {
|
||||
// ART aborts the process on a call through a null object, so a callback
|
||||
// that was already released has to stop here rather than at the JNI call.
|
||||
if (tun_interface == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
ATTACH_JNI();
|
||||
env->CallVoidMethod(static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_protect,
|
||||
fd);
|
||||
const auto accepted = env->CallBooleanMethod(static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_protect,
|
||||
fd);
|
||||
if (jni_clear_exception(env)) {
|
||||
return 0;
|
||||
}
|
||||
return accepted == JNI_TRUE ? 1 : 0;
|
||||
}
|
||||
|
||||
static char *
|
||||
call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
|
||||
const char *source,
|
||||
const char *target,
|
||||
const int uid) {
|
||||
static int call_tun_interface_resolve_uid_impl(void *tun_interface, const int protocol,
|
||||
const char *source,
|
||||
const char *target) {
|
||||
if (tun_interface == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
ATTACH_JNI();
|
||||
const auto source_string = new_string(source);
|
||||
const auto target_string = new_string(target);
|
||||
const auto uid = env->CallIntMethod(static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_resolve_uid,
|
||||
protocol,
|
||||
source_string,
|
||||
target_string);
|
||||
const auto failed = jni_clear_exception(env);
|
||||
if (source_string != nullptr) {
|
||||
env->DeleteLocalRef(source_string);
|
||||
}
|
||||
if (target_string != nullptr) {
|
||||
env->DeleteLocalRef(target_string);
|
||||
}
|
||||
return failed ? -1 : uid;
|
||||
}
|
||||
|
||||
static char *call_tun_interface_resolve_package_impl(void *tun_interface, const int uid) {
|
||||
if (tun_interface == nullptr) {
|
||||
return strdup("");
|
||||
}
|
||||
ATTACH_JNI();
|
||||
const auto package_name = reinterpret_cast<jstring>(env->CallObjectMethod(
|
||||
static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_resolve_process,
|
||||
protocol,
|
||||
source_string,
|
||||
target_string,
|
||||
m_tun_interface_resolve_package,
|
||||
uid));
|
||||
env->DeleteLocalRef(source_string);
|
||||
env->DeleteLocalRef(target_string);
|
||||
jni_clear_exception(env);
|
||||
const auto result = get_string(package_name);
|
||||
if (package_name != nullptr) {
|
||||
env->DeleteLocalRef(package_name);
|
||||
@@ -127,12 +156,18 @@ call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
|
||||
}
|
||||
|
||||
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
|
||||
if (invoke_interface == nullptr) {
|
||||
return;
|
||||
}
|
||||
ATTACH_JNI();
|
||||
const auto value = new_string(data);
|
||||
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"
|
||||
@@ -149,74 +184,21 @@ JNI_OnLoad(JavaVM *vm, void *) {
|
||||
|
||||
const auto c_invoke_interface = find_class("com/follow/clash/core/InvokeInterface");
|
||||
|
||||
m_tun_interface_protect = find_method(c_tun_interface, "protect", "(I)V");
|
||||
m_tun_interface_resolve_process = find_method(c_tun_interface, "resolverProcess",
|
||||
"(ILjava/lang/String;Ljava/lang/String;I)Ljava/lang/String;");
|
||||
m_tun_interface_protect = find_method(c_tun_interface, "protect", "(I)Z");
|
||||
m_tun_interface_resolve_uid = find_method(c_tun_interface, "resolveUid",
|
||||
"(ILjava/lang/String;Ljava/lang/String;)I");
|
||||
m_tun_interface_resolve_package = find_method(c_tun_interface, "resolvePackage",
|
||||
"(I)Ljava/lang/String;");
|
||||
m_invoke_interface_result = find_method(c_invoke_interface, "onResult",
|
||||
"(Ljava/lang/String;)V");
|
||||
|
||||
|
||||
protect_func = &call_tun_interface_protect_impl;
|
||||
resolve_process_func = &call_tun_interface_resolve_process_impl;
|
||||
resolve_uid_func = &call_tun_interface_resolve_uid_impl;
|
||||
resolve_package_func = &call_tun_interface_resolve_package_impl;
|
||||
result_func = &call_invoke_interface_result_impl;
|
||||
release_object_func = &release_jni_object_impl;
|
||||
free_string_func = &free_string_impl;
|
||||
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
#else
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
|
||||
jstring stack, jstring address, jstring dns) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_forceGC(JNIEnv *env, jobject thiz) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_setEventListener(JNIEnv *env, jobject thiz, jobject cb) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
return env->NewStringUTF("{}");
|
||||
}
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
return env->NewStringUTF("{}");
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
|
||||
jstring setup_params_string, jobject cb) {
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -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); \
|
||||
|
||||
@@ -11,7 +11,7 @@ object Core {
|
||||
stack: String,
|
||||
address: String,
|
||||
dns: String,
|
||||
)
|
||||
): Boolean
|
||||
|
||||
external fun forceGC()
|
||||
|
||||
@@ -29,31 +29,30 @@ object Core {
|
||||
fun startTun(
|
||||
fd: Int,
|
||||
protect: (Int) -> Boolean,
|
||||
resolverProcess: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress, uid: Int) -> String,
|
||||
resolveUid: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress) -> Int,
|
||||
resolvePackage: (uid: Int) -> String,
|
||||
stack: String,
|
||||
address: String,
|
||||
dns: String,
|
||||
) {
|
||||
startTun(
|
||||
): Boolean {
|
||||
return startTun(
|
||||
fd,
|
||||
object : TunInterface {
|
||||
override fun protect(fd: Int) {
|
||||
protect(fd)
|
||||
}
|
||||
override fun protect(fd: Int): Boolean = protect(fd)
|
||||
|
||||
override fun resolverProcess(
|
||||
override fun resolveUid(
|
||||
protocol: Int,
|
||||
source: String,
|
||||
target: String,
|
||||
uid: Int,
|
||||
): String {
|
||||
return resolverProcess(
|
||||
): Int {
|
||||
return resolveUid(
|
||||
protocol,
|
||||
parseInetSocketAddress(source),
|
||||
parseInetSocketAddress(target),
|
||||
uid,
|
||||
)
|
||||
}
|
||||
|
||||
override fun resolvePackage(uid: Int): String = resolvePackage(uid)
|
||||
},
|
||||
stack,
|
||||
address,
|
||||
|
||||
@@ -4,7 +4,11 @@ import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
interface TunInterface {
|
||||
fun protect(fd: Int)
|
||||
/** Returns false when the socket could not be kept out of the tunnel. */
|
||||
fun protect(fd: Int): Boolean
|
||||
|
||||
fun resolverProcess(protocol: Int, source: String, target: String, uid: Int): String
|
||||
/** Returns the uid owning the connection, or -1 when the system will not name it. */
|
||||
fun resolveUid(protocol: Int, source: String, target: String): Int
|
||||
|
||||
fun resolvePackage(uid: Int): String
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.follow.clash.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.ProxyInfo
|
||||
import android.os.Binder
|
||||
@@ -41,26 +42,26 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
}
|
||||
private val uidPackageNameMap = ConcurrentHashMap<Int, String>()
|
||||
|
||||
private fun resolverProcess(
|
||||
private fun resolveUid(
|
||||
protocol: Int,
|
||||
source: InetSocketAddress,
|
||||
target: InetSocketAddress,
|
||||
uid: Int,
|
||||
): String {
|
||||
val nextUid = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
connectivity?.getConnectionOwnerUid(protocol, source, target) ?: -1
|
||||
} else {
|
||||
uid
|
||||
}
|
||||
if (nextUid == -1) {
|
||||
return ""
|
||||
}
|
||||
return uidPackageNameMap.getOrPut(nextUid) {
|
||||
packageManager
|
||||
.getPackagesForUid(nextUid)
|
||||
?.firstOrNull()
|
||||
.orEmpty()
|
||||
): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return -1
|
||||
}
|
||||
return connectivity?.getConnectionOwnerUid(protocol, source, target) ?: -1
|
||||
}
|
||||
|
||||
private fun resolvePackage(uid: Int): String {
|
||||
val cached = uidPackageNameMap[uid]
|
||||
if (cached != null) return cached
|
||||
val packageName = packageManager
|
||||
.getPackagesForUid(uid)
|
||||
?.firstOrNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
return uidPackageNameMap.putIfAbsent(uid, packageName) ?: packageName
|
||||
}
|
||||
|
||||
private val VpnOptions.tunAddress
|
||||
@@ -145,14 +146,22 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
synchronized(tunLock) {
|
||||
tunRunning = true
|
||||
try {
|
||||
Core.startTun(
|
||||
fd = fd,
|
||||
protect = this::protect,
|
||||
resolverProcess = this::resolverProcess,
|
||||
stack = options.stack,
|
||||
address = options.tunAddress,
|
||||
dns = options.tunDns,
|
||||
)
|
||||
// A Core that fails to take the descriptor leaves the system
|
||||
// routes pointing at an interface nothing reads, and it no
|
||||
// longer keeps its own sockets out of them: every connection
|
||||
// then hangs until it times out. Tear the VPN down instead of
|
||||
// reporting a start that only looks successful.
|
||||
check(
|
||||
Core.startTun(
|
||||
fd = fd,
|
||||
protect = this::protect,
|
||||
resolveUid = this::resolveUid,
|
||||
resolvePackage = this::resolvePackage,
|
||||
stack = options.stack,
|
||||
address = options.tunAddress,
|
||||
dns = options.tunDns,
|
||||
),
|
||||
) { "Core rejected the tun file descriptor" }
|
||||
} catch (error: Exception) {
|
||||
stopTunLocked()
|
||||
throw error
|
||||
@@ -216,15 +225,28 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
if (!accessControl.enable) return
|
||||
when (accessControl.mode) {
|
||||
AccessControlMode.ACCEPT_SELECTED -> {
|
||||
(accessControl.acceptList + packageName).forEach(::addAllowedApplication)
|
||||
(accessControl.acceptList + packageName).forEach { name ->
|
||||
addApplication(name, ::addAllowedApplication)
|
||||
}
|
||||
}
|
||||
|
||||
AccessControlMode.REJECT_SELECTED -> {
|
||||
(accessControl.rejectList - packageName).forEach(::addDisallowedApplication)
|
||||
(accessControl.rejectList - packageName).forEach { name ->
|
||||
addApplication(name, ::addDisallowedApplication)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A selected package that was uninstalled since must not veto the whole tunnel.
|
||||
private fun addApplication(name: String, add: (String) -> Builder) {
|
||||
try {
|
||||
add(name)
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
GlobalState.log("Access control skipped an uninstalled package: $name")
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
try {
|
||||
modules.start()
|
||||
|
||||
@@ -4,4 +4,5 @@ data class NotificationParams(
|
||||
val title: String = "FlClash",
|
||||
val stopText: String = "STOP",
|
||||
val onlyStatisticsProxy: Boolean = false,
|
||||
val showStopAction: Boolean = true,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -33,6 +33,7 @@ import kotlinx.coroutines.launch
|
||||
private data class ExtendedNotificationParams(
|
||||
val title: String,
|
||||
val stopText: String,
|
||||
val showStopAction: Boolean,
|
||||
val contentText: String,
|
||||
)
|
||||
|
||||
@@ -40,6 +41,7 @@ private val NotificationParams.extended: ExtendedNotificationParams
|
||||
get() = ExtendedNotificationParams(
|
||||
title,
|
||||
stopText,
|
||||
showStopAction,
|
||||
Core.getSpeedTrafficText(onlyStatisticsProxy),
|
||||
)
|
||||
|
||||
@@ -106,11 +108,14 @@ internal class NotificationModule(
|
||||
setContentTitle(params.title)
|
||||
setContentText(params.contentText)
|
||||
clearActions()
|
||||
addAction(
|
||||
0,
|
||||
params.stopText,
|
||||
QuickAction.STOP.quickIntent.toPendingIntent,
|
||||
).build()
|
||||
if (params.showStopAction) {
|
||||
addAction(
|
||||
0,
|
||||
params.stopText,
|
||||
QuickAction.STOP.quickIntent.toPendingIntent,
|
||||
)
|
||||
}
|
||||
build()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,658 @@
|
||||
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 `notification params carry the stop action switch`() {
|
||||
val params = ServiceStateMachine.notificationParams(
|
||||
SharedState(showStopAction = false),
|
||||
)
|
||||
|
||||
assertEquals(false, params.showStopAction)
|
||||
}
|
||||
|
||||
@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,158 @@
|
||||
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,
|
||||
"showStopAction": false,
|
||||
"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(false, state.showStopAction)
|
||||
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)
|
||||
assertEquals(true, defaults.showStopAction)
|
||||
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,102 @@
|
||||
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 `the lease is still released when the reason throws`() {
|
||||
var releases = 0
|
||||
val lease = BroadcastLease { releases++ }
|
||||
|
||||
try {
|
||||
lease.release { throw RuntimeException("boom") }
|
||||
} catch (_: RuntimeException) {
|
||||
// expected: propagated after the lease is released
|
||||
}
|
||||
|
||||
assertEquals(1, releases)
|
||||
assertTrue(lease.isReleased)
|
||||
}
|
||||
|
||||
@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,77 @@
|
||||
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)
|
||||
assertEquals(true, defaults.showStopAction)
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"rule": "Rule",
|
||||
"rules": "Rules",
|
||||
"global": "Global",
|
||||
"direct": "Direct",
|
||||
"dashboard": "Dashboard",
|
||||
@@ -8,16 +9,19 @@
|
||||
"profiles": "Profiles",
|
||||
"tools": "Tools",
|
||||
"logs": "Logs",
|
||||
"logsDesc": "Log capture records",
|
||||
"logsDesc": "Captured log records",
|
||||
"resources": "Resources",
|
||||
"resourcesDesc": "External resource related info",
|
||||
"resourcesDesc": "Information about external resources",
|
||||
"trafficUsage": "Traffic usage",
|
||||
"networkSpeed": "Network speed",
|
||||
"outboundMode": "Outbound mode",
|
||||
"networkDetection": "Network detection",
|
||||
"upload": "Upload",
|
||||
"download": "Download",
|
||||
"nullProfileDesc": "No profile, Please add a profile",
|
||||
"usedTraffic": "Used traffic",
|
||||
"totalTraffic": "Total traffic",
|
||||
"expireTime": "Expiration time",
|
||||
"nullProfileDesc": "No profiles yet, please add one first",
|
||||
"settings": "Settings",
|
||||
"language": "Language",
|
||||
"defaultText": "Default",
|
||||
@@ -27,51 +31,53 @@
|
||||
"en": "English",
|
||||
"ja": "Japanese",
|
||||
"ru": "Russian",
|
||||
"zh_CN": "Simplified Chinese",
|
||||
"zhCN": "Simplified Chinese",
|
||||
"theme": "Theme",
|
||||
"themeDesc": "Set dark mode,adjust the color",
|
||||
"themeDesc": "Set dark mode and adjust colors",
|
||||
"override": "Override",
|
||||
"allowLan": "AllowLan",
|
||||
"allowLanDesc": "Allow access proxy through the LAN",
|
||||
"allowLan": "Allow LAN",
|
||||
"allowLanDesc": "Allow proxy access over the LAN",
|
||||
"tun": "TUN",
|
||||
"tunDesc": "only effective in administrator mode",
|
||||
"tunDesc": "Only effective in administrator mode",
|
||||
"minimizeOnExit": "Minimize on exit",
|
||||
"minimizeOnExitDesc": "Modify the default system exit event",
|
||||
"minimizeOnExitDesc": "Override the default system exit behavior",
|
||||
"autoLaunch": "Auto launch",
|
||||
"autoLaunchDesc": "Follow the system self startup",
|
||||
"silentLaunch": "SilentLaunch",
|
||||
"autoLaunchDesc": "Launch automatically at system startup",
|
||||
"silentLaunch": "Silent launch",
|
||||
"silentLaunchDesc": "Start in the background",
|
||||
"autoRun": "AutoRun",
|
||||
"autoRunDesc": "Auto run when the application is opened",
|
||||
"autoRun": "Auto run",
|
||||
"autoRunDesc": "Run automatically when the app opens",
|
||||
"logcat": "Logcat",
|
||||
"logcatDesc": "Disabling will hide the log entry",
|
||||
"autoCheckUpdate": "Auto check updates",
|
||||
"autoCheckUpdateDesc": "Auto check for updates when the app starts",
|
||||
"accessControl": "AccessControl",
|
||||
"accessControlDesc": "Configure application access proxy",
|
||||
"logcatDesc": "Disabling hides the log entry point",
|
||||
"autoCheckUpdate": "Auto check for updates",
|
||||
"autoCheckUpdateDesc": "Check for updates automatically when the app starts",
|
||||
"checkCertificate": "Verify TLS certificates",
|
||||
"checkCertificateDesc": "Reject untrusted certificates. Turning this off exposes subscriptions and backups to man-in-the-middle attacks",
|
||||
"accessControl": "Access control",
|
||||
"accessControlDesc": "Control which apps use the proxy",
|
||||
"application": "Application",
|
||||
"applicationDesc": "Modify application related settings",
|
||||
"applicationDesc": "Adjust application settings",
|
||||
"edit": "Edit",
|
||||
"confirm": "Confirm",
|
||||
"update": "Update",
|
||||
"add": "Add",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"seconds": "Seconds",
|
||||
"seconds": "seconds",
|
||||
"qrcode": "QR code",
|
||||
"qrcodeDesc": "Scan QR code to obtain profile",
|
||||
"qrcodeDesc": "Scan a QR code to obtain a profile",
|
||||
"url": "URL",
|
||||
"urlDesc": "Obtain profile through URL",
|
||||
"urlDesc": "Obtain a profile from a URL",
|
||||
"file": "File",
|
||||
"fileDesc": "Directly upload profile",
|
||||
"fileDesc": "Upload a profile file directly",
|
||||
"name": "Name",
|
||||
"profileNameNullValidationDesc": "Please input the profile name",
|
||||
"profileUrlNullValidationDesc": "Please input the profile URL",
|
||||
"profileUrlInvalidValidationDesc": "Please input a valid profile URL",
|
||||
"profileNameNullValidationDesc": "Please enter the profile name",
|
||||
"profileUrlNullValidationDesc": "Please enter the profile URL",
|
||||
"profileUrlInvalidValidationDesc": "Please enter a valid profile URL",
|
||||
"autoUpdate": "Auto update",
|
||||
"autoUpdateInterval": "Auto update interval (minutes)",
|
||||
"profileAutoUpdateIntervalNullValidationDesc": "Please enter the auto update interval time",
|
||||
"profileAutoUpdateIntervalInvalidValidationDesc": "Please input a valid interval time format",
|
||||
"autoUpdateInterval": "Auto-update interval (minutes)",
|
||||
"profileAutoUpdateIntervalNullValidationDesc": "Please enter the auto-update interval",
|
||||
"profileAutoUpdateIntervalInvalidValidationDesc": "Please enter a valid interval",
|
||||
"themeMode": "Theme mode",
|
||||
"themeColor": "Theme color",
|
||||
"preview": "Preview",
|
||||
@@ -80,20 +86,20 @@
|
||||
"dark": "Dark",
|
||||
"importFromURL": "Import from URL",
|
||||
"submit": "Submit",
|
||||
"doYouWantToPass": "Do you want to pass",
|
||||
"createProfileFromUrlTip": "Do you want to create a profile from {url}?",
|
||||
"create": "Create",
|
||||
"pleaseUploadValidQrcode": "Please upload a valid QR code",
|
||||
"blacklistMode": "Blacklist mode",
|
||||
"whitelistMode": "Whitelist mode",
|
||||
"selectAll": "Select all",
|
||||
"cancelSelectAll": "Cancel select all",
|
||||
"cancelSelectAll": "Deselect all",
|
||||
"appAccessControl": "App access control",
|
||||
"accessControlAllowDesc": "Only allow selected app to enter VPN",
|
||||
"accessControlNotAllowDesc": "The selected application will be excluded from VPN",
|
||||
"accessControlAllowDesc": "Only selected apps go through the VPN",
|
||||
"accessControlNotAllowDesc": "Selected apps are excluded from the VPN",
|
||||
"accessControlDisabledDesc": "App access control is disabled",
|
||||
"selected": "Selected",
|
||||
"proxyPort": "ProxyPort",
|
||||
"port": "Port",
|
||||
"logLevel": "LogLevel",
|
||||
"logLevel": "Log level",
|
||||
"show": "Show",
|
||||
"exit": "Exit",
|
||||
"systemProxy": "System proxy",
|
||||
@@ -104,61 +110,67 @@
|
||||
"startVpn": "Starting VPN...",
|
||||
"stopVpn": "Stopping VPN...",
|
||||
"compatible": "Compatibility mode",
|
||||
"notSelectedTip": "The current proxy group cannot be selected.",
|
||||
"tip": "tip",
|
||||
"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",
|
||||
"backupSuccess": "Backup success",
|
||||
"backupSuccess": "Backup successful",
|
||||
"noInfo": "No info",
|
||||
"pleaseBindWebDAV": "Please bind WebDAV",
|
||||
"bind": "Bind",
|
||||
"connectivity": "Connectivity:",
|
||||
"connectivity": "Connectivity: ",
|
||||
"webDAVConfiguration": "WebDAV configuration",
|
||||
"address": "Address",
|
||||
"addressHelp": "WebDAV server address",
|
||||
"addressTip": "Please enter a valid WebDAV address",
|
||||
"password": "Password",
|
||||
"authentication": "Authentication",
|
||||
"authenticationDesc": "Require credentials on the local proxy port to keep other local apps from using it",
|
||||
"authenticationSystemProxyDesc": "Not applied while authentication is enabled",
|
||||
"checkUpdate": "Check for updates",
|
||||
"discoverNewVersion": "Discover the new version",
|
||||
"checkUpdateError": "The current application is already the latest version",
|
||||
"goDownload": "Go to download",
|
||||
"discoverNewVersion": "New version found",
|
||||
"checkUpdateError": "The app is already up to date",
|
||||
"goDownload": "Download",
|
||||
"unknown": "Unknown",
|
||||
"country": "Country",
|
||||
"country": "Region",
|
||||
"search": "Search",
|
||||
"allowBypass": "Allow applications to bypass VPN",
|
||||
"allowBypassDesc": "Some apps can bypass VPN when turned on",
|
||||
"externalController": "ExternalController",
|
||||
"externalControllerDesc": "Once enabled, the Clash kernel can be controlled on port 9090",
|
||||
"ipv6Desc": "When turned on it will be able to receive IPv6 traffic",
|
||||
"allowBypass": "Allow apps to bypass VPN",
|
||||
"allowBypassDesc": "When enabled, some apps can bypass the VPN",
|
||||
"externalController": "External controller",
|
||||
"externalControllerDesc": "When enabled, the Clash core can be controlled on port 9090",
|
||||
"ipv6Desc": "When enabled, IPv6 traffic can be received",
|
||||
"app": "App",
|
||||
"general": "General",
|
||||
"systemProxyDesc": "Attach HTTP proxy to VpnService",
|
||||
"systemProxyDesc": "Set the system proxy",
|
||||
"userAgent": "User-Agent",
|
||||
"unifiedDelay": "Unified delay",
|
||||
"unifiedDelayDesc": "Remove extra delays such as handshaking",
|
||||
"unifiedDelayDesc": "Remove extra delays such as handshakes",
|
||||
"tcpConcurrent": "TCP concurrent",
|
||||
"tcpConcurrentDesc": "Enabling it will allow TCP concurrency",
|
||||
"geodataLoader": "Geo Low Memory Mode",
|
||||
"geodataLoaderDesc": "Enabling will use the Geo low memory loader",
|
||||
"tcpConcurrentDesc": "Allow concurrent TCP connections",
|
||||
"geodataLoader": "Geo low-memory mode",
|
||||
"geodataLoaderDesc": "Use the low-memory Geo loader",
|
||||
"requests": "Requests",
|
||||
"requestsDesc": "View recently request records",
|
||||
"requestsDesc": "View recent request records",
|
||||
"findProcessMode": "Find process",
|
||||
"init": "Init",
|
||||
"infiniteTime": "Long term effective",
|
||||
"infiniteTime": "Never expires",
|
||||
"connections": "Connections",
|
||||
"connectionsDesc": "View current connections data",
|
||||
"inbound": "Inbound",
|
||||
"connectionsDesc": "View current connection data",
|
||||
"intranetIP": "Intranet IP",
|
||||
"view": "View",
|
||||
"cut": "Cut",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"testUrl": "Test url",
|
||||
"testUrl": "Test URL",
|
||||
"sync": "Sync",
|
||||
"exclude": "Hidden from recent tasks",
|
||||
"excludeDesc": "When the app is in the background, the app is hidden from the recent task",
|
||||
"exclude": "Hide from recent tasks",
|
||||
"excludeDesc": "Hide the app from recent tasks while it is in the background",
|
||||
"expand": "Standard",
|
||||
"shrink": "Shrink",
|
||||
"min": "Min",
|
||||
"shrink": "Compact",
|
||||
"min": "Minimal",
|
||||
"tab": "Tab",
|
||||
"list": "List",
|
||||
"delay": "Delay",
|
||||
@@ -171,140 +183,152 @@
|
||||
"externalLink": "External link",
|
||||
"otherContributors": "Other contributors",
|
||||
"autoCloseConnections": "Auto close connections",
|
||||
"autoCloseConnectionsDesc": "Auto close connections after change node",
|
||||
"onlyStatisticsProxy": "Only statistics proxy",
|
||||
"onlyStatisticsProxyDesc": "When turned on, only statistics proxy traffic",
|
||||
"autoCloseConnectionsDesc": "Close connections automatically after switching nodes",
|
||||
"onlyStatisticsProxy": "Only count proxy traffic",
|
||||
"onlyStatisticsProxyDesc": "When enabled, only proxy traffic is counted",
|
||||
"showNotificationStopAction": "Stop button in notification",
|
||||
"showNotificationStopActionDesc": "Show a stop button on the persistent notification. Turn it off if your system keeps the notification expanded because of it",
|
||||
"pureBlackMode": "Pure black mode",
|
||||
"keepAliveIntervalDesc": "Tcp keep alive interval",
|
||||
"keepAliveIntervalDesc": "TCP keep-alive interval",
|
||||
"entries": " entries",
|
||||
"local": "Local",
|
||||
"remote": "Remote",
|
||||
"remoteBackupDesc": "Backup local data to WebDAV",
|
||||
"localBackupDesc": "Backup local data to local",
|
||||
"remoteBackupDesc": "Back up data to WebDAV",
|
||||
"localBackupDesc": "Back up data locally",
|
||||
"mode": "Mode",
|
||||
"time": "Time",
|
||||
"source": "Source",
|
||||
"action": "Action",
|
||||
"intelligentSelected": "Intelligent selection",
|
||||
"clipboardImport": "Clipboard import",
|
||||
"clipboardExport": "Export clipboard",
|
||||
"intelligentSelected": "Smart selection",
|
||||
"clipboardImport": "Import from clipboard",
|
||||
"clipboardExport": "Export to clipboard",
|
||||
"layout": "Layout",
|
||||
"tight": "Tight",
|
||||
"standard": "Standard",
|
||||
"loose": "Loose",
|
||||
"profilesSort": "Profiles sort",
|
||||
"profilesSort": "Sort profiles",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"dnsDesc": "Update DNS related settings",
|
||||
"dnsDesc": "Update DNS-related settings",
|
||||
"key": "Key",
|
||||
"value": "Value",
|
||||
"hostsDesc": "Add Hosts",
|
||||
"hostsDesc": "Append hosts",
|
||||
"vpnTip": "Changes take effect after restarting the VPN",
|
||||
"vpnEnableDesc": "Auto routes all system traffic through VpnService",
|
||||
"vpnEnableDesc": "Route all system traffic through VpnService automatically",
|
||||
"options": "Options",
|
||||
"loopback": "Loopback unlock tool",
|
||||
"loopbackDesc": "Used for UWP loopback unlocking",
|
||||
"providers": "Providers",
|
||||
"loopbackDesc": "Used for UWP loopback exemption",
|
||||
"providers": "External resources",
|
||||
"proxyProviders": "Proxy providers",
|
||||
"ruleProviders": "Rule providers",
|
||||
"overrideDns": "Override Dns",
|
||||
"overrideDnsDesc": "Turning it on will override the DNS options in the profile",
|
||||
"subscriptionInfo": "Subscription info",
|
||||
"overrideDns": "Override DNS",
|
||||
"overrideDnsDesc": "When enabled, the DNS options in the profile are overridden",
|
||||
"status": "Status",
|
||||
"statusDesc": "System DNS will be used when turned off",
|
||||
"preferH3Desc": "Prioritize the use of DOH's http/3",
|
||||
"statusDesc": "When disabled, the system DNS is used",
|
||||
"preferH3Desc": "Prefer HTTP/3 for DoH",
|
||||
"respectRules": "Respect rules",
|
||||
"respectRulesDesc": "DNS connection following rules, need to configure proxy-server-nameserver",
|
||||
"respectRulesDesc": "DNS connections follow rules; requires proxy-server-nameserver",
|
||||
"dnsMode": "DNS mode",
|
||||
"fakeipRange": "Fakeip range",
|
||||
"fakeipFilter": "Fakeip filter",
|
||||
"fakeipRange": "Fake-IP range",
|
||||
"fakeipFilter": "Fake-IP filter",
|
||||
"defaultNameserver": "Default nameserver",
|
||||
"defaultNameserverDesc": "For resolving DNS server",
|
||||
"defaultNameserverDesc": "Used to resolve DNS servers",
|
||||
"nameserver": "Nameserver",
|
||||
"nameserverDesc": "For resolving domain",
|
||||
"nameserverDesc": "Used to resolve domains",
|
||||
"useHosts": "Use hosts",
|
||||
"useSystemHosts": "Use system hosts",
|
||||
"nameserverPolicy": "Nameserver policy",
|
||||
"nameserverPolicyDesc": "Specify the corresponding nameserver policy",
|
||||
"nameserverPolicyDesc": "Specify the nameserver policy for matching domains",
|
||||
"proxyNameserver": "Proxy nameserver",
|
||||
"proxyNameserverDesc": "Domain for resolving proxy nodes",
|
||||
"proxyNameserverDesc": "Used to resolve proxy node domains",
|
||||
"fallback": "Fallback",
|
||||
"fallbackDesc": "Generally use offshore DNS",
|
||||
"fallbackDesc": "Usually an overseas DNS",
|
||||
"fallbackFilter": "Fallback filter",
|
||||
"geoipCode": "Geoip code",
|
||||
"ipcidr": "Ipcidr",
|
||||
"geoipCode": "GeoIP code",
|
||||
"ipcidr": "IP/CIDR",
|
||||
"domain": "Domain",
|
||||
"reset": "Reset",
|
||||
"action_view": "Show/Hide",
|
||||
"action_start": "Start/Stop",
|
||||
"action_mode": "Switch mode",
|
||||
"action_proxy": "System proxy",
|
||||
"action_tun": "TUN",
|
||||
"actionView": "Show/Hide",
|
||||
"actionStart": "Start/Stop",
|
||||
"actionMode": "Switch mode",
|
||||
"actionProxy": "System proxy",
|
||||
"actionTun": "TUN",
|
||||
"disclaimer": "Disclaimer",
|
||||
"disclaimerDesc": "This software is only used for non-commercial purposes such as learning exchanges and scientific research. It is strictly prohibited to use this software for commercial purposes. Any commercial activity, if any, has nothing to do with this software.",
|
||||
"disclaimerDesc": "This software is intended only for non-commercial uses such as learning and research. Using it for any commercial purpose is strictly prohibited; any commercial activity is unrelated to this software.",
|
||||
"agree": "Agree",
|
||||
"hotkeyManagement": "Hotkey Management",
|
||||
"hotkeyManagementDesc": "Use keyboard to control applications",
|
||||
"pressKeyboard": "Please press the keyboard.",
|
||||
"inputCorrectHotkey": "Please enter the correct hotkey",
|
||||
"hotkeyManagement": "Hotkey management",
|
||||
"hotkeyManagementDesc": "Control the app with the keyboard",
|
||||
"pressKeyboard": "Please press a key",
|
||||
"inputCorrectHotkey": "Please enter a valid hotkey",
|
||||
"hotkeyConflict": "Hotkey conflict",
|
||||
"remove": "Remove",
|
||||
"noHotKey": "No HotKey",
|
||||
"noHotKey": "No hotkeys yet",
|
||||
"noNetwork": "No network",
|
||||
"ipv6InboundDesc": "Allow IPv6 inbound",
|
||||
"exportLogs": "Export logs",
|
||||
"exportSuccess": "Export Success",
|
||||
"exportSuccess": "Export successful",
|
||||
"iconStyle": "Icon style",
|
||||
"onlyIcon": "Icon",
|
||||
"onlyIcon": "Icon only",
|
||||
"stackMode": "Stack mode",
|
||||
"network": "Network",
|
||||
"networkDesc": "Modify network-related settings",
|
||||
"bypassDomain": "Bypass domain",
|
||||
"bypassDomainDesc": "Only takes effect when the system proxy is enabled",
|
||||
"resetTip": "Make sure to reset",
|
||||
"networkDesc": "Adjust network-related settings",
|
||||
"bypassDomain": "Bypass domains",
|
||||
"bypassDomainDesc": "Only takes effect while the system proxy is enabled",
|
||||
"resetTip": "Are you sure you want to reset?",
|
||||
"icon": "Icon",
|
||||
"noData": "No data",
|
||||
"fontFamily": "FontFamily",
|
||||
"fontFamily": "Font family",
|
||||
"toggle": "Toggle",
|
||||
"system": "System",
|
||||
"routeMode": "Route mode",
|
||||
"routeMode_bypassPrivate": "Bypass private route address",
|
||||
"routeMode_config": "Use config",
|
||||
"routeAddress": "Route address",
|
||||
"routeAddressDesc": "Config listen route address",
|
||||
"pleaseInputAdminPassword": "Please enter the admin password",
|
||||
"copyEnvVar": "Copying environment variables",
|
||||
"routeModeBypassPrivate": "Bypass private addresses",
|
||||
"routeModeConfig": "Use config",
|
||||
"routeAddress": "Route addresses",
|
||||
"routeAddressDesc": "Configure the listened route addresses",
|
||||
"interfaceNameMode": "Outbound interface",
|
||||
"interfaceNameModeClear": "Clear",
|
||||
"interfaceNameModeFollow": "Follow config",
|
||||
"interfaceNameModeCustom": "Custom",
|
||||
"interfaceName": "Interface name",
|
||||
"interfaceNameDesc": "Network interface used for outbound connections",
|
||||
"copyEnvVar": "Copy environment variables",
|
||||
"memoryInfo": "Memory info",
|
||||
"cancel": "Cancel",
|
||||
"fileIsUpdate": "The file has been modified. Do you want to save the changes?",
|
||||
"profileHasUpdate": "The profile has been modified. Do you want to disable auto update?",
|
||||
"hasCacheChange": "Do you want to cache the changes?",
|
||||
"copySuccess": "Copy success",
|
||||
"fileIsUpdate": "The file has been modified. Save the changes?",
|
||||
"profileHasUpdate": "The profile has been modified. Turn off auto update?",
|
||||
"hasCacheChange": "Cache the changes?",
|
||||
"copySuccess": "Copied successfully",
|
||||
"copyLink": "Copy link",
|
||||
"exportFile": "Export file",
|
||||
"cacheCorrupt": "The cache is corrupt. Do you want to clear it?",
|
||||
"detectionTip": "Relying on third-party api is for reference only",
|
||||
"cacheCorrupt": "The cache is corrupted. Clear it?",
|
||||
"detectionTip": "Relies on a third-party API; for reference only",
|
||||
"listen": "Listen",
|
||||
"undo": "undo",
|
||||
"redo": "redo",
|
||||
"none": "none",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"none": "None",
|
||||
"basicConfig": "Basic configuration",
|
||||
"basicConfigDesc": "Modify the basic configuration globally",
|
||||
"advancedConfig": "Advanced configuration",
|
||||
"advancedConfigDesc": "Provide diverse configuration options",
|
||||
"selectedCountTitle": "{count} items have been selected",
|
||||
"advancedConfigDesc": "Provides diverse configuration options",
|
||||
"selectedCountTitle": "{count} selected",
|
||||
"addRule": "Add rule",
|
||||
"ruleName": "Rule name",
|
||||
"content": "Content",
|
||||
"subRule": "Sub rule",
|
||||
"subRule": "Sub-rule",
|
||||
"ruleTarget": "Rule target",
|
||||
"matchTargetTitle": "Match target",
|
||||
"matchTarget": "MATCH-TARGET",
|
||||
"followProfile": "Follow profile",
|
||||
"matchTargetDesc": "Where rules targeting MATCH-TARGET go. Defaults to the target of the final MATCH rule in this profile.",
|
||||
"selectMatchTarget": "Select MATCH-TARGET",
|
||||
"sourceIp": "Source IP",
|
||||
"noResolve": "No resolve IP",
|
||||
"saveChanges": "Do you want to save the changes?",
|
||||
"findProcessModeDesc": "There is a certain performance loss after opening",
|
||||
"tabAnimationDesc": "Effective only in mobile view",
|
||||
"noResolve": "Don't resolve IP",
|
||||
"saveChanges": "Save the changes?",
|
||||
"findProcessModeDesc": "Enabling causes some performance loss",
|
||||
"tabAnimationDesc": "Only effective in mobile view",
|
||||
"colorSchemes": "Color schemes",
|
||||
"palette": "Palette",
|
||||
"tonalSpotScheme": "TonalSpot",
|
||||
"tonalSpotScheme": "Tonal spot",
|
||||
"fidelityScheme": "Fidelity",
|
||||
"monochromeScheme": "Monochrome",
|
||||
"neutralScheme": "Neutral",
|
||||
@@ -312,58 +336,61 @@
|
||||
"expressiveScheme": "Expressive",
|
||||
"contentScheme": "Content",
|
||||
"rainbowScheme": "Rainbow",
|
||||
"fruitSaladScheme": "FruitSalad",
|
||||
"fruitSaladScheme": "Fruit salad",
|
||||
"developerMode": "Developer mode",
|
||||
"developerModeEnableTip": "Developer mode is enabled.",
|
||||
"messageTest": "Message test",
|
||||
"messageTestTip": "This is a message.",
|
||||
"crashTest": "Crash test",
|
||||
"crashDetected": "Crash detected",
|
||||
"crashDetectedTip": "The app crashed during the previous run. To prevent repeated crashes, the current profile has been cleared and automatic configuration setup was skipped.",
|
||||
"clearData": "Clear Data",
|
||||
"textScale": "Text Scaling",
|
||||
"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",
|
||||
"systemApp": "System APP",
|
||||
"noNetworkApp": "No network APP",
|
||||
"systemApp": "System apps",
|
||||
"noNetworkApp": "No-network apps",
|
||||
"restoreStrategy": "Restore strategy",
|
||||
"restoreStrategy_override": "Override",
|
||||
"restoreStrategy_compatible": "Compatible",
|
||||
"restoreStrategyOverride": "Override",
|
||||
"restoreStrategyCompatible": "Compatible",
|
||||
"logsTest": "Logs test",
|
||||
"emptyTip": "{label} cannot be empty",
|
||||
"urlTip": "{label} must be a url",
|
||||
"urlTip": "{label} must be a URL",
|
||||
"numberTip": "{label} must be a number",
|
||||
"interval": "Interval",
|
||||
"existsTip": "Current {label} already exists",
|
||||
"deleteTip": "Are you sure you want to delete the current {label}?",
|
||||
"existsTip": "{label} already exists",
|
||||
"maxLengthTip": "{label} must be at most {max} characters",
|
||||
"multipleValuesTip": "Separate multiple values with commas",
|
||||
"deleteTip": "Are you sure you want to delete this {label}?",
|
||||
"deleteMultipTip": "Are you sure you want to delete the selected {label}?",
|
||||
"nullTip": "No {label} yet",
|
||||
"script": "Script",
|
||||
"color": "Color",
|
||||
"rename": "Rename",
|
||||
"unnamed": "Unnamed",
|
||||
"pleaseEnterScriptName": "Please enter a script name",
|
||||
"mixedPort": "Mixed Port",
|
||||
"socksPort": "Socks Port",
|
||||
"redirPort": "Redir Port",
|
||||
"tproxyPort": "Tproxy Port",
|
||||
"mixedPort": "Mixed port",
|
||||
"socksPort": "SOCKS port",
|
||||
"redirPort": "Redir port",
|
||||
"tproxyPort": "TProxy port",
|
||||
"portTip": "{label} must be between 1024 and 49151",
|
||||
"portConflictTip": "Please enter a different port",
|
||||
"import": "Import",
|
||||
"importFile": "Import from file",
|
||||
"importUrl": "Import from URL",
|
||||
"autoSetSystemDns": "Auto set system DNS",
|
||||
"autoSetSystemDns": "Auto-set system DNS",
|
||||
"details": "{label} details",
|
||||
"creationTime": "Creation time",
|
||||
"process": "Process",
|
||||
"host": "Host",
|
||||
"destination": "Destination",
|
||||
"destinationGeoIP": "Destination GeoIP",
|
||||
"destinationIPASN": "Destination IPASN",
|
||||
"destinationIPASN": "Destination IP ASN",
|
||||
"specialProxy": "Special proxy",
|
||||
"specialRules": "special rules",
|
||||
"specialRules": "Special rules",
|
||||
"remoteDestination": "Remote destination",
|
||||
"networkType": "Network type",
|
||||
"proxyChains": "Proxy chains",
|
||||
"proxyChains": "Proxy chain",
|
||||
"log": "Log",
|
||||
"connection": "Connection",
|
||||
"request": "Request",
|
||||
@@ -374,184 +401,221 @@
|
||||
"forceRestartCoreTip": "Are you sure you want to force restart the core?",
|
||||
"dnsHijacking": "DNS hijacking",
|
||||
"coreStatus": "Core status",
|
||||
"dataCollectionTip": "Data Collection Notice",
|
||||
"dataCollectionContent": "This app uses Firebase Crashlytics to collect crash information to improve app stability.\nThe collected data includes device information and crash details, but does not contain personal sensitive data.\nYou can disable this feature in settings.",
|
||||
"crashlytics": "Crash Analysis",
|
||||
"crashlyticsTip": "When enabled, automatically uploads crash logs without sensitive information when the app crashes",
|
||||
"appendSystemDns": "Append System DNS",
|
||||
"appendSystemDnsTip": "Forcefully append system DNS to the configuration",
|
||||
"dataCollectionTip": "Data collection notice",
|
||||
"dataCollectionContent": "This app uses Firebase Crashlytics to collect crash information to improve stability.\nThe collected data includes device information and crash details, and contains no personally sensitive data.\nYou can turn this off in settings.",
|
||||
"crashlytics": "Crash analytics",
|
||||
"crashlyticsTip": "When enabled, crash logs without sensitive information are uploaded automatically when the app crashes",
|
||||
"appendSystemDns": "Append system DNS",
|
||||
"appendSystemDnsTip": "Force-append the system DNS to the configuration",
|
||||
"editRule": "Edit rule",
|
||||
"overrideMode": "Override mode",
|
||||
"standardModeDesc": "Standard mode, override basic configuration, provide simple rule addition capability",
|
||||
"scriptModeDesc": "Script mode, use external extension scripts, provide one-click override configuration capability",
|
||||
"standardModeDesc": "Standard mode: overrides the basic configuration and offers simple rule additions",
|
||||
"scriptModeDesc": "Script mode: uses external extension scripts to override the configuration in one click",
|
||||
"addedRules": "Added rules",
|
||||
"controlGlobalAddedRules": "Control global added rules",
|
||||
"overrideScript": "Override script",
|
||||
"goToConfigureScript": "Go to configure script",
|
||||
"goToConfigureScript": "Go to script configuration",
|
||||
"editGlobalRules": "Edit global rules",
|
||||
"externalFetch": "External fetch",
|
||||
"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}}",
|
||||
"hoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
|
||||
"minutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
|
||||
"justNow": "Just now",
|
||||
"noLongerRemind": "Don't remind again",
|
||||
"accessControlSettings": "Access Control Settings",
|
||||
"turnOn": "Turn On",
|
||||
"turnOff": "Turn Off",
|
||||
"vpnConfigChangeDetected": "VPN configuration change detected",
|
||||
"noLongerRemind": "Don't remind me again",
|
||||
"accessControlSettings": "Access control settings",
|
||||
"turnOn": "Turn on",
|
||||
"turnOff": "Turn off",
|
||||
"vpnConfigChangeDetected": "VPN-related configuration change detected",
|
||||
"restart": "Restart",
|
||||
"speedStatistics": "Speed statistics",
|
||||
"resetPageChangesTip": "The current page has changes. Are you sure you want to reset?",
|
||||
"resetPageChangesTip": "This page has changes. Are you sure you want to reset?",
|
||||
"overwriteTypeCustom": "Custom",
|
||||
"overwriteTypeCustomDesc": "Custom mode, fully customize proxy groups and rules",
|
||||
"overwriteTypeCustomDesc": "Custom mode: fully customize proxy groups and rules",
|
||||
"unknownNetworkError": "Unknown network error",
|
||||
"restoreException": "Recovery exception",
|
||||
"networkException": "Network exception, please check your connection and try again",
|
||||
"restoreException": "Restore error",
|
||||
"networkException": "Network error, please check your connection and try again",
|
||||
"invalidBackupFile": "Invalid backup file",
|
||||
"pruneCache": "Prune cache",
|
||||
"backupAndRestore": "Backup and Restore",
|
||||
"backupAndRestore": "Backup and restore",
|
||||
"backupAndRestoreDesc": "Sync data via WebDAV or files",
|
||||
"restore": "Restore",
|
||||
"restoreSuccess": "Restore success",
|
||||
"restoreFromWebDAVDesc": "Restore data via WebDAV",
|
||||
"restoreFromFileDesc": "Restore data via file",
|
||||
"restoreOnlyConfig": "Restore configuration files only",
|
||||
"restoreSuccess": "Restore successful",
|
||||
"restoreFromWebDAVDesc": "Restore data from WebDAV",
|
||||
"restoreFromFileDesc": "Restore data from a file",
|
||||
"restoreOnlyConfig": "Restore profiles only",
|
||||
"restoreAllData": "Restore all data",
|
||||
"addProfile": "Add Profile",
|
||||
"delayTest": "Delay Test",
|
||||
"addProfile": "Add profile",
|
||||
"delayTest": "Delay test",
|
||||
"proxyGroupEmpty": "Proxy group is empty",
|
||||
"proxyGroupNameEmpty": "Proxy group name cannot be empty",
|
||||
"proxyGroupNameDuplicate": "Proxy group name is duplicate",
|
||||
"proxyGroupNameDuplicate": "Duplicate proxy group name",
|
||||
"confirmExitWindow": "Are you sure you want to exit the current window?",
|
||||
"dataChangedSave": "Data changes detected, do you want to save?",
|
||||
"dataChangedSave": "Data changes detected. Save them?",
|
||||
"selectProxyProviders": "Select proxy providers",
|
||||
"proxyFilter": "Proxy filter",
|
||||
"optional": "Optional",
|
||||
"maxFailedTimes": "Max failed times",
|
||||
"maxFailedTimes": "Max failures",
|
||||
"testInterval": "Test interval",
|
||||
"excludeProxyFilter": "Exclude proxy filter",
|
||||
"excludeType": "Exclude type",
|
||||
"expectedStatus": "Expected status",
|
||||
"selectProxies": "Select proxies",
|
||||
"inputProxyGroupName": "Input proxy group name",
|
||||
"inputProxyGroupName": "Enter the proxy group name",
|
||||
"helperCorruptTip": "Helper service unavailable; TUN mode cannot be enabled. Reinstall FlClash to restore it.",
|
||||
"coreBlockedByPolicyTip": "Windows refused to run FlClashCore.exe (error {code}). An app control policy such as Smart App Control or AppLocker blocks unsigned programs; allow FlClash in that policy or turn it off, then try again.",
|
||||
"coreBlockedBySmartAppControlTip": "Windows Smart App Control blocked FlClashCore.exe because it is not signed. Open Windows Security → App & browser control → Smart App Control settings, choose Off, then start FlClash again. Smart App Control cannot be turned back on without reinstalling Windows.",
|
||||
"hideFromList": "Hide from list",
|
||||
"testWhenUsed": "Test when used",
|
||||
"disableUDP": "Disable UDP",
|
||||
"confirmDeleteProxyGroup": "Are you sure you want to delete the current proxy group?",
|
||||
"confirmDeleteProxyGroup": "Are you sure you want to delete this proxy group?",
|
||||
"ruleEmpty": "Rule is empty",
|
||||
"inputRuleContent": "Input rule content",
|
||||
"inputRuleContent": "Enter the rule content",
|
||||
"ruleSet": "Rule set",
|
||||
"selectRuleSet": "Please select rule set",
|
||||
"selectRuleSet": "Please select a rule set",
|
||||
"splitStrategy": "Split strategy",
|
||||
"selectSplitStrategy": "Please select split strategy",
|
||||
"selectSubRule": "Please select sub rule",
|
||||
"noResolveHostname": "No resolve hostname",
|
||||
"selectSplitStrategy": "Please select a split strategy",
|
||||
"selectSubRule": "Please select a sub-rule",
|
||||
"noResolveHostname": "Don't resolve hostname",
|
||||
"matchSourceIp": "Match source IP",
|
||||
"basicInfo": "Basic info",
|
||||
"additionalParameters": "Additional parameters",
|
||||
"proxyType": "Proxy type",
|
||||
"basicStrategy": "Basic strategy",
|
||||
"basicStrategy": "Basic strategies",
|
||||
"editProxy": "Edit proxy",
|
||||
"includeAllProxyProviders": "Include all proxy providers",
|
||||
"includeAllProxyProvidersTip": "When enabled, it will override the imported proxy providers",
|
||||
"includeAllProxyProvidersTip": "When enabled, the imported proxy providers are overridden",
|
||||
"addProxyProviders": "Add proxy providers",
|
||||
"includeAllProxies": "Include all proxies",
|
||||
"includeAllProxiesTip": "Import all proxies not containing proxy groups, additional proxy groups can be added below",
|
||||
"proxiesEmpty": "Proxies is empty",
|
||||
"includeAllProxiesTip": "Imports all proxies outside proxy groups; extra proxy groups can be added below",
|
||||
"proxiesEmpty": "Proxies are empty",
|
||||
"addProxies": "Add proxies",
|
||||
"addProxyGroup": "Add proxy group",
|
||||
"editProxyGroup": "Edit proxy group",
|
||||
"confirmOverwriteTip": "Existing data will be overwritten after confirmation",
|
||||
"configDataDetected": "Data detected in configuration",
|
||||
"confirmOverwriteTip": "Confirming will overwrite existing data",
|
||||
"configDataDetected": "Data detected in the configuration",
|
||||
"quickFill": "Quick fill",
|
||||
"iconUrl": "Icon URL",
|
||||
"iconRecords": "Icon records",
|
||||
"noRecords": "No records",
|
||||
"custom": "Custom",
|
||||
"ruleActionDomainDesc": "Match full domain",
|
||||
"ruleActionDomainSuffixDesc": "Match domain suffix",
|
||||
"ruleActionDomainKeywordDesc": "Match domain keyword",
|
||||
"ruleActionDomainRegexDesc": "Wildcard match, only supports * and ? wildcards",
|
||||
"ruleActionGeositeDesc": "Match domains within Geosite",
|
||||
"ruleActionIpCidrDesc": "Match IP address range",
|
||||
"ruleActionIpCidr6Desc": "Match IP address range, IP-CIDR6 is just an alias",
|
||||
"ruleActionIpSuffixDesc": "Match IP suffix range",
|
||||
"ruleActionIpAsnDesc": "Match IP's ASN",
|
||||
"ruleActionGeoipDesc": "Match IP's country code",
|
||||
"ruleActionSrcGeoipDesc": "Match source IP's country code",
|
||||
"ruleActionSrcIpAsnDesc": "Match source IP's ASN",
|
||||
"ruleActionSrcIpCidrDesc": "Match source IP address range",
|
||||
"ruleActionSrcIpSuffixDesc": "Match source IP suffix range",
|
||||
"ruleActionDstPortDesc": "Match request target port range",
|
||||
"ruleActionSrcPortDesc": "Match request source port range",
|
||||
"ruleActionInPortDesc": "Match inbound port",
|
||||
"ruleActionInTypeDesc": "Match inbound type",
|
||||
"ruleActionInUserDesc": "Match inbound username, supports multiple usernames separated by /",
|
||||
"ruleActionInNameDesc": "Match inbound name",
|
||||
"ruleActionProcessPathDesc": "Match using full process path",
|
||||
"ruleActionProcessPathRegexDesc": "Match using process path regex",
|
||||
"ruleActionProcessNameDesc": "Match using process name, matches package name on Android",
|
||||
"ruleActionProcessNameRegexDesc": "Match using process name regex, matches package name on Android",
|
||||
"ruleActionUidDesc": "Match Linux USER ID",
|
||||
"ruleActionDomainDesc": "Match the full domain",
|
||||
"ruleActionDomainSuffixDesc": "Match a domain suffix",
|
||||
"ruleActionDomainKeywordDesc": "Match a domain keyword",
|
||||
"ruleActionDomainRegexDesc": "Match a domain regex",
|
||||
"ruleActionDomainWildcardDesc": "Wildcard match; only * and ? are supported",
|
||||
"ruleActionGeositeDesc": "Match domains in Geosite",
|
||||
"ruleActionIpCidrDesc": "Match an IP address range",
|
||||
"ruleActionIpCidr6Desc": "Match an IP address range; IP-CIDR6 is just an alias",
|
||||
"ruleActionIpSuffixDesc": "Match an IP suffix range",
|
||||
"ruleActionIpAsnDesc": "Match the IP's ASN",
|
||||
"ruleActionGeoipDesc": "Match the IP's country code",
|
||||
"ruleActionSrcGeoipDesc": "Match the source IP's country code",
|
||||
"ruleActionSrcIpAsnDesc": "Match the source IP's ASN",
|
||||
"ruleActionSrcIpCidrDesc": "Match a source IP address range",
|
||||
"ruleActionSrcIpSuffixDesc": "Match a source IP suffix range",
|
||||
"ruleActionDstPortDesc": "Match the destination port range",
|
||||
"ruleActionSrcPortDesc": "Match the source port range",
|
||||
"ruleActionInPortDesc": "Match the inbound port",
|
||||
"ruleActionInTypeDesc": "Match the inbound type",
|
||||
"ruleActionInUserDesc": "Match the inbound username; separate multiple usernames with /",
|
||||
"ruleActionInNameDesc": "Match the inbound name",
|
||||
"ruleActionRematchNameDesc": "Match the rematch name; separate multiple names with /",
|
||||
"ruleActionProcessPathDesc": "Match by the full process path",
|
||||
"ruleActionProcessPathRegexDesc": "Match by process path regex",
|
||||
"ruleActionProcessPathWildcardDesc": "Match by process path wildcard; only * and ? are supported",
|
||||
"ruleActionProcessNameDesc": "Match by process name; matches the package name on Android",
|
||||
"ruleActionProcessNameRegexDesc": "Match by process name regex; matches the package name on Android",
|
||||
"ruleActionProcessNameWildcardDesc": "Match by process name wildcard; only * and ? are supported",
|
||||
"ruleActionUidDesc": "Match the Linux user ID",
|
||||
"ruleActionNetworkDesc": "Match TCP or UDP",
|
||||
"ruleActionDscpDesc": "Match DSCP mark (tproxy udp inbound only)",
|
||||
"ruleActionRuleSetDesc": "Reference rule set, requires rule-providers configuration",
|
||||
"ruleActionDscpDesc": "Match the DSCP mark (tproxy UDP inbound only)",
|
||||
"ruleActionRuleSetDesc": "Reference a rule set; requires rule-providers",
|
||||
"ruleActionAndDesc": "Logical rule AND",
|
||||
"ruleActionOrDesc": "Logical rule OR",
|
||||
"ruleActionNotDesc": "Logical rule NOT",
|
||||
"ruleActionSubRuleDesc": "Match to sub-rule, pay attention to the use of parentheses",
|
||||
"ruleActionSubRuleDesc": "Match into a sub-rule; mind the parentheses",
|
||||
"ruleActionMatchDesc": "Match all requests, no conditions needed",
|
||||
"subRuleEmpty": "Sub rule is empty",
|
||||
"subRuleEmpty": "Sub-rule is empty",
|
||||
"proxyProvidersNotEmpty": "Proxy providers cannot be empty",
|
||||
"contentNotEmpty": "Content cannot be empty",
|
||||
"subRuleNotEmpty": "Sub rule cannot be empty",
|
||||
"subRuleNotEmpty": "Sub-rule cannot be empty",
|
||||
"splitStrategyNotEmpty": "Split strategy cannot be empty",
|
||||
"proxyProvidersEmpty": "Proxy providers is empty",
|
||||
"proxyProvidersEmpty": "Proxy providers are empty",
|
||||
"timeout": "Timeout",
|
||||
"invalidSubRule": "{subRule} is an invalid SUB_RULE",
|
||||
"invalidPolicy": "{target} is an invalid policy",
|
||||
"invalidProxyProvider": "{providerName} is an invalid proxy provider",
|
||||
"invalidProxy": "{proxyName} is an invalid proxy",
|
||||
"proxyGroupDetectedAbnormal": "Detected current proxy group is abnormal",
|
||||
"proxyProviderDetectedAbnormal": "Detected selected proxy providers are abnormal",
|
||||
"proxyDetectedAbnormal": "Detected selected proxies are abnormal",
|
||||
"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.",
|
||||
"proxyGroupDetectedAbnormal": "The current proxy group is abnormal",
|
||||
"proxyProviderDetectedAbnormal": "The selected proxy providers are abnormal",
|
||||
"proxyDetectedAbnormal": "The selected proxies are abnormal",
|
||||
"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 list\n\nWhen you are done, return to the app to continue. 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": "Affected by the system, this status may not always be accurate.",
|
||||
"locationPermission": "Location Permission",
|
||||
"locationPermissionDesc": "According to system requirements, obtaining the Wi-Fi name requires you to grant location permission.",
|
||||
"ignoreBatteryOptimization": "Ignore battery optimization",
|
||||
"batteryOptimizationDesc": "To keep the app running in the background, disable battery optimization for it. Tap to open settings.",
|
||||
"batteryOptimizationStatusTip": "Due to system limitations, the battery optimization status cannot be read correctly while running",
|
||||
"locationPermission": "Location permission",
|
||||
"locationPermissionDesc": "The system requires location permission to read the Wi-Fi name. On Android choose \"Allow all the time\", otherwise the Wi-Fi name cannot be read while the app is in the background.",
|
||||
"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",
|
||||
"locationPermissionDeniedMessage": "Location permission was denied, so the current Wi-Fi name cannot be obtained. Please open location permission manually in system settings.",
|
||||
"excludeSsidsDesc": "When connected to Wi-Fi with an excluded SSID, the app's running state switches automatically",
|
||||
"ssidsEmpty": "SSIDs are empty",
|
||||
"onDemand": "On demand",
|
||||
"onDemandDesc": "Configure the app's running state for specific scenarios",
|
||||
"locationPermissionDeniedMessage": "Location permission was denied, so the current Wi-Fi name cannot be read. Please enable location permission manually in system settings.",
|
||||
"addSsid": "Add SSID",
|
||||
"editSsid": "Edit SSID",
|
||||
"authorized": "Authorized",
|
||||
"tapToAuthorize": "Tap to authorize",
|
||||
"suspended": "Suspended...",
|
||||
"geoOptions": "Geo Options",
|
||||
"geoAutoUpdate": "Auto Update",
|
||||
"geoAutoUpdateInterval": "Auto Update Interval",
|
||||
"geoAutoUpdateIntervalTip": "Auto update interval must be greater than 0",
|
||||
"geoOptions": "Geo options",
|
||||
"geoAutoUpdate": "Auto update",
|
||||
"geoAutoUpdateInterval": "Auto-update interval",
|
||||
"geoAutoUpdateIntervalTip": "The auto-update interval must be greater than 0",
|
||||
"hours": "hours",
|
||||
"hoursCount": "{count} hours",
|
||||
"geoResources": "Geo Resources",
|
||||
"geoUpdating": "Updating {name}...",
|
||||
"hoursCount": "{count, plural, =1{1 hour} other{{count} hours}}",
|
||||
"geoResources": "Geo resources",
|
||||
"geoSkipped": "{name} is already up to date",
|
||||
"geoUpdated": "{name} updated",
|
||||
"secondsCount": "{count} seconds",
|
||||
"entriesCount": "{count} entries"
|
||||
"secondsCount": "{count, plural, =1{1 second} other{{count} seconds}}",
|
||||
"entriesCount": "{count, plural, =1{1 entry} other{{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 down",
|
||||
"pinWindow": "Pin window",
|
||||
"unpinWindow": "Unpin window",
|
||||
"exitFullScreen": "Exit full screen",
|
||||
"toggleLabel": "Toggle labels",
|
||||
"torch": "Flashlight",
|
||||
"pickFromAlbum": "Choose from album",
|
||||
"blockConnection": "Block connection",
|
||||
"closeConnections": "Close connections",
|
||||
"scrollToSelected": "Scroll to selected",
|
||||
"showMore": "Expand",
|
||||
"showLess": "Collapse",
|
||||
"previousMatch": "Previous match",
|
||||
"nextMatch": "Next match",
|
||||
"clearSearch": "Clear search",
|
||||
"addWidget": "Add widget",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"authorize": "Authorize",
|
||||
"installedAppsPermissionRequired": "App list permission required",
|
||||
"installedAppsPermissionDesc": "This system hides the installed app list until the permission is granted. Authorize it to configure the per-app proxy.",
|
||||
"installedAppsPermissionDeniedMessage": "The app list permission was denied, so installed apps cannot be listed. Please grant it manually in system settings."
|
||||
}
|
||||
|
||||
@@ -1,77 +1,83 @@
|
||||
{
|
||||
"rule": "ルール",
|
||||
"rules": "ルール",
|
||||
"global": "グローバル",
|
||||
"direct": "ダイレクト",
|
||||
"dashboard": "ダッシュボード",
|
||||
"proxies": "プロキシ",
|
||||
"profile": "プロファイル",
|
||||
"profiles": "プロファイル一覧",
|
||||
"profiles": "プロファイル",
|
||||
"tools": "ツール",
|
||||
"logs": "ログ",
|
||||
"logsDesc": "ログキャプチャ記録",
|
||||
"logsDesc": "キャプチャしたログの記録",
|
||||
"resources": "リソース",
|
||||
"resourcesDesc": "外部リソース関連情報",
|
||||
"trafficUsage": "トラフィック使用量",
|
||||
"resourcesDesc": "外部リソースの関連情報",
|
||||
"trafficUsage": "トラフィック統計",
|
||||
"networkSpeed": "ネットワーク速度",
|
||||
"outboundMode": "アウトバウンドモード",
|
||||
"networkDetection": "ネットワーク検出",
|
||||
"upload": "アップロード",
|
||||
"download": "ダウンロード",
|
||||
"nullProfileDesc": "プロファイルがありません。追加してください",
|
||||
"usedTraffic": "使用済みトラフィック",
|
||||
"totalTraffic": "合計トラフィック",
|
||||
"expireTime": "有効期限",
|
||||
"nullProfileDesc": "プロファイルがありません。先にプロファイルを追加してください",
|
||||
"settings": "設定",
|
||||
"language": "言語",
|
||||
"defaultText": "デフォルト",
|
||||
"more": "詳細",
|
||||
"more": "その他",
|
||||
"other": "その他",
|
||||
"about": "について",
|
||||
"about": "アプリについて",
|
||||
"en": "英語",
|
||||
"ja": "日本語",
|
||||
"ru": "ロシア語",
|
||||
"zh_CN": "簡体字中国語",
|
||||
"zhCN": "簡体字中国語",
|
||||
"theme": "テーマ",
|
||||
"themeDesc": "ダークモードの設定、色の調整",
|
||||
"themeDesc": "ダークモードの設定と色の調整",
|
||||
"override": "上書き",
|
||||
"allowLan": "LANを許可",
|
||||
"allowLanDesc": "LAN経由でのプロキシアクセスを許可",
|
||||
"allowLan": "LANプロキシ",
|
||||
"allowLanDesc": "LAN経由でのプロキシ利用を許可します",
|
||||
"tun": "TUN",
|
||||
"tunDesc": "管理者モードでのみ有効",
|
||||
"minimizeOnExit": "終了時に最小化",
|
||||
"minimizeOnExitDesc": "システムの終了イベントを変更",
|
||||
"minimizeOnExitDesc": "システム標準の終了動作を変更します",
|
||||
"autoLaunch": "自動起動",
|
||||
"autoLaunchDesc": "システムの自動起動に従う",
|
||||
"silentLaunch": "バックグラウンド起動",
|
||||
"silentLaunchDesc": "バックグラウンドで起動",
|
||||
"autoLaunchDesc": "システム起動時に自動的に起動します",
|
||||
"silentLaunch": "サイレント起動",
|
||||
"silentLaunchDesc": "バックグラウンドで起動します",
|
||||
"autoRun": "自動実行",
|
||||
"autoRunDesc": "アプリ起動時に自動実行",
|
||||
"logcat": "ログキャット",
|
||||
"logcatDesc": "無効化するとログエントリを非表示",
|
||||
"autoCheckUpdate": "自動更新チェック",
|
||||
"autoCheckUpdateDesc": "起動時に更新を自動チェック",
|
||||
"autoRunDesc": "アプリを開いたときに自動的に実行します",
|
||||
"logcat": "ログキャプチャ",
|
||||
"logcatDesc": "無効にするとログの入り口が非表示になります",
|
||||
"autoCheckUpdate": "更新の自動チェック",
|
||||
"autoCheckUpdateDesc": "アプリ起動時に更新を自動的にチェックします",
|
||||
"checkCertificate": "TLS証明書を検証",
|
||||
"checkCertificateDesc": "信頼できない証明書を拒否します。無効にすると、サブスクリプションやバックアップが中間者攻撃にさらされます",
|
||||
"accessControl": "アクセス制御",
|
||||
"accessControlDesc": "アプリケーションのプロキシアクセスを設定",
|
||||
"accessControlDesc": "プロキシを利用するアプリを設定します",
|
||||
"application": "アプリケーション",
|
||||
"applicationDesc": "アプリ関連設定を変更",
|
||||
"applicationDesc": "アプリケーション関連の設定を変更します",
|
||||
"edit": "編集",
|
||||
"confirm": "確認",
|
||||
"confirm": "OK",
|
||||
"update": "更新",
|
||||
"add": "追加",
|
||||
"save": "保存",
|
||||
"delete": "削除",
|
||||
"seconds": "秒",
|
||||
"qrcode": "QRコード",
|
||||
"qrcodeDesc": "QRコードをスキャンしてプロファイルを取得",
|
||||
"qrcodeDesc": "QRコードをスキャンしてプロファイルを取得します",
|
||||
"url": "URL",
|
||||
"urlDesc": "URL経由でプロファイルを取得",
|
||||
"urlDesc": "URLからプロファイルを取得します",
|
||||
"file": "ファイル",
|
||||
"fileDesc": "プロファイルを直接アップロード",
|
||||
"fileDesc": "プロファイルファイルを直接アップロードします",
|
||||
"name": "名前",
|
||||
"profileNameNullValidationDesc": "プロファイル名を入力してください",
|
||||
"profileUrlNullValidationDesc": "プロファイルURLを入力してください",
|
||||
"profileUrlNullValidationDesc": "プロファイルのURLを入力してください",
|
||||
"profileUrlInvalidValidationDesc": "有効なプロファイルURLを入力してください",
|
||||
"autoUpdate": "自動更新",
|
||||
"autoUpdateInterval": "自動更新間隔(分)",
|
||||
"profileAutoUpdateIntervalNullValidationDesc": "自動更新間隔を入力してください",
|
||||
"profileAutoUpdateIntervalInvalidValidationDesc": "有効な間隔形式を入力してください",
|
||||
"profileAutoUpdateIntervalInvalidValidationDesc": "有効な間隔を入力してください",
|
||||
"themeMode": "テーマモード",
|
||||
"themeColor": "テーマカラー",
|
||||
"preview": "プレビュー",
|
||||
@@ -80,18 +86,18 @@
|
||||
"dark": "ダーク",
|
||||
"importFromURL": "URLからインポート",
|
||||
"submit": "送信",
|
||||
"doYouWantToPass": "通過させますか?",
|
||||
"createProfileFromUrlTip": "{url} からプロファイルを作成しますか?",
|
||||
"create": "作成",
|
||||
"pleaseUploadValidQrcode": "有効なQRコードをアップロードしてください",
|
||||
"blacklistMode": "ブラックリストモード",
|
||||
"whitelistMode": "ホワイトリストモード",
|
||||
"selectAll": "すべて選択",
|
||||
"cancelSelectAll": "全選択解除",
|
||||
"cancelSelectAll": "すべて選択解除",
|
||||
"appAccessControl": "アプリアクセス制御",
|
||||
"accessControlAllowDesc": "選択したアプリのみVPNを許可",
|
||||
"accessControlNotAllowDesc": "選択したアプリをVPNから除外",
|
||||
"accessControlAllowDesc": "選択したアプリのみVPNを経由します",
|
||||
"accessControlNotAllowDesc": "選択したアプリはVPNから除外されます",
|
||||
"accessControlDisabledDesc": "アプリアクセス制御は無効です",
|
||||
"selected": "選択済み",
|
||||
"proxyPort": "プロキシポート",
|
||||
"port": "ポート",
|
||||
"logLevel": "ログレベル",
|
||||
"show": "表示",
|
||||
@@ -100,264 +106,285 @@
|
||||
"project": "プロジェクト",
|
||||
"core": "コア",
|
||||
"tabAnimation": "タブアニメーション",
|
||||
"desc": "ClashMetaベースのマルチプラットフォームプロキシクライアント。シンプルで使いやすく、オープンソースで広告なし。",
|
||||
"startVpn": "VPNを開始中...",
|
||||
"stopVpn": "VPNを停止中...",
|
||||
"desc": "ClashMetaベースのマルチプラットフォーム対応プロキシクライアント。シンプルで使いやすく、オープンソースで広告もありません。",
|
||||
"startVpn": "VPNを起動しています...",
|
||||
"stopVpn": "VPNを停止しています...",
|
||||
"compatible": "互換モード",
|
||||
"notSelectedTip": "現在のプロキシグループは選択できません",
|
||||
"changeProxyFailedTip": "プロキシの切り替えに失敗したため、前回の選択に戻しました",
|
||||
"databaseWriteFailedTip": "変更の保存に失敗したため、元に戻しました",
|
||||
"tip": "ヒント",
|
||||
"account": "アカウント",
|
||||
"backup": "バックアップ",
|
||||
"backupSuccess": "バックアップ成功",
|
||||
"noInfo": "情報なし",
|
||||
"pleaseBindWebDAV": "WebDAVをバインドしてください",
|
||||
"bind": "バインド",
|
||||
"connectivity": "接続性:",
|
||||
"backupSuccess": "バックアップが完了しました",
|
||||
"noInfo": "情報がありません",
|
||||
"pleaseBindWebDAV": "WebDAVを連携してください",
|
||||
"bind": "連携",
|
||||
"connectivity": "接続状態:",
|
||||
"webDAVConfiguration": "WebDAV設定",
|
||||
"address": "アドレス",
|
||||
"addressHelp": "WebDAVサーバーアドレス",
|
||||
"addressTip": "有効なWebDAVアドレスを入力",
|
||||
"addressHelp": "WebDAVサーバーのアドレス",
|
||||
"addressTip": "有効なWebDAVアドレスを入力してください",
|
||||
"password": "パスワード",
|
||||
"authentication": "認証",
|
||||
"authenticationDesc": "ローカルプロキシポートに認証を要求し、他のアプリによる無断利用を防ぎます",
|
||||
"authenticationSystemProxyDesc": "認証が有効な間は適用されません",
|
||||
"checkUpdate": "更新を確認",
|
||||
"discoverNewVersion": "新バージョンを発見",
|
||||
"checkUpdateError": "アプリは最新版です",
|
||||
"discoverNewVersion": "新しいバージョンが見つかりました",
|
||||
"checkUpdateError": "すでに最新バージョンです",
|
||||
"goDownload": "ダウンロードへ",
|
||||
"unknown": "不明",
|
||||
"country": "国",
|
||||
"country": "地域",
|
||||
"search": "検索",
|
||||
"allowBypass": "アプリがVPNをバイパスすることを許可",
|
||||
"allowBypassDesc": "有効化すると一部アプリがVPNをバイパス",
|
||||
"allowBypass": "アプリによるVPNバイパスを許可",
|
||||
"allowBypassDesc": "有効にすると、一部のアプリがVPNをバイパスできます",
|
||||
"externalController": "外部コントローラー",
|
||||
"externalControllerDesc": "有効化するとClashコアをポート9090で制御可能",
|
||||
"ipv6Desc": "有効化するとIPv6トラフィックを受信可能",
|
||||
"externalControllerDesc": "有効にすると、ポート9090でClashコアを制御できます",
|
||||
"ipv6Desc": "有効にすると、IPv6トラフィックを受信できます",
|
||||
"app": "アプリ",
|
||||
"general": "一般",
|
||||
"systemProxyDesc": "HTTPプロキシをVpnServiceに接続",
|
||||
"userAgent": "ユーザーエージェント",
|
||||
"systemProxyDesc": "システムプロキシを設定します",
|
||||
"userAgent": "User-Agent",
|
||||
"unifiedDelay": "統一遅延",
|
||||
"unifiedDelayDesc": "ハンドシェイクなどの余分な遅延を削除",
|
||||
"tcpConcurrent": "TCP並列処理",
|
||||
"tcpConcurrentDesc": "TCP並列処理を許可",
|
||||
"unifiedDelayDesc": "ハンドシェイクなどの余分な遅延を除きます",
|
||||
"tcpConcurrent": "TCP同時接続",
|
||||
"tcpConcurrentDesc": "有効にすると、TCPの同時接続を許可します",
|
||||
"geodataLoader": "Geo低メモリモード",
|
||||
"geodataLoaderDesc": "有効化するとGeo低メモリローダーを使用",
|
||||
"geodataLoaderDesc": "有効にすると、低メモリのGeoローダーを使用します",
|
||||
"requests": "リクエスト",
|
||||
"requestsDesc": "最近のリクエスト記録を表示",
|
||||
"requestsDesc": "最近のリクエスト記録を表示します",
|
||||
"findProcessMode": "プロセス検出",
|
||||
"init": "初期化",
|
||||
"infiniteTime": "長期有効",
|
||||
"infiniteTime": "無期限",
|
||||
"connections": "接続",
|
||||
"connectionsDesc": "現在の接続データを表示",
|
||||
"inbound": "インバウンド",
|
||||
"connectionsDesc": "現在の接続データを表示します",
|
||||
"intranetIP": "イントラネットIP",
|
||||
"view": "表示",
|
||||
"cut": "切り取り",
|
||||
"copy": "コピー",
|
||||
"paste": "貼り付け",
|
||||
"testUrl": "URLテスト",
|
||||
"testUrl": "テストURL",
|
||||
"sync": "同期",
|
||||
"exclude": "最近のタスクから非表示",
|
||||
"excludeDesc": "アプリがバックグラウンド時に最近のタスクから非表示",
|
||||
"exclude": "最近のタスクから隠す",
|
||||
"excludeDesc": "バックグラウンド時に、最近のタスクからアプリを隠します",
|
||||
"expand": "標準",
|
||||
"shrink": "縮小",
|
||||
"min": "最小化",
|
||||
"shrink": "コンパクト",
|
||||
"min": "最小",
|
||||
"tab": "タブ",
|
||||
"list": "リスト",
|
||||
"delay": "遅延",
|
||||
"style": "スタイル",
|
||||
"size": "サイズ",
|
||||
"sort": "並び替え",
|
||||
"columns": "列",
|
||||
"sort": "並べ替え",
|
||||
"columns": "列数",
|
||||
"proxyGroup": "プロキシグループ",
|
||||
"go": "移動",
|
||||
"go": "開く",
|
||||
"externalLink": "外部リンク",
|
||||
"otherContributors": "その他の貢献者",
|
||||
"autoCloseConnections": "接続を自動閉じる",
|
||||
"autoCloseConnectionsDesc": "ノード変更後に接続を自動閉じる",
|
||||
"onlyStatisticsProxy": "プロキシのみ統計",
|
||||
"onlyStatisticsProxyDesc": "有効化するとプロキシトラフィックのみ統計",
|
||||
"pureBlackMode": "純黒モード",
|
||||
"autoCloseConnections": "接続を自動的に閉じる",
|
||||
"autoCloseConnectionsDesc": "ノードの切り替え後、接続を自動的に閉じます",
|
||||
"onlyStatisticsProxy": "プロキシのみ集計",
|
||||
"onlyStatisticsProxyDesc": "有効にすると、プロキシのトラフィックのみを集計します",
|
||||
"showNotificationStopAction": "通知に停止ボタンを表示",
|
||||
"showNotificationStopActionDesc": "常駐通知に停止ボタンを表示します。これが原因で通知が常に展開される場合はオフにしてください",
|
||||
"pureBlackMode": "ピュアブラックモード",
|
||||
"keepAliveIntervalDesc": "TCPキープアライブ間隔",
|
||||
"entries": " エントリ",
|
||||
"entries": " 件",
|
||||
"local": "ローカル",
|
||||
"remote": "リモート",
|
||||
"remoteBackupDesc": "WebDAVにデータをバックアップ",
|
||||
"localBackupDesc": "ローカルにデータをバックアップ",
|
||||
"remoteBackupDesc": "WebDAVにデータをバックアップします",
|
||||
"localBackupDesc": "ローカルにデータをバックアップします",
|
||||
"mode": "モード",
|
||||
"time": "時間",
|
||||
"time": "時刻",
|
||||
"source": "ソース",
|
||||
"action": "アクション",
|
||||
"intelligentSelected": "インテリジェント選択",
|
||||
"intelligentSelected": "スマート選択",
|
||||
"clipboardImport": "クリップボードからインポート",
|
||||
"clipboardExport": "クリップボードにエクスポート",
|
||||
"clipboardExport": "クリップボードへエクスポート",
|
||||
"layout": "レイアウト",
|
||||
"tight": "密",
|
||||
"tight": "コンパクト",
|
||||
"standard": "標準",
|
||||
"loose": "疎",
|
||||
"profilesSort": "プロファイルの並び替え",
|
||||
"loose": "ゆったり",
|
||||
"profilesSort": "プロファイルの並べ替え",
|
||||
"start": "開始",
|
||||
"stop": "停止",
|
||||
"dnsDesc": "DNS関連設定の更新",
|
||||
"dnsDesc": "DNS関連の設定を更新します",
|
||||
"key": "キー",
|
||||
"value": "値",
|
||||
"hostsDesc": "ホストを追加",
|
||||
"vpnTip": "変更はVPN再起動後に有効",
|
||||
"vpnEnableDesc": "VpnService経由で全システムトラフィックをルーティング",
|
||||
"hostsDesc": "Hostsを追加します",
|
||||
"vpnTip": "変更はVPNの再起動後に有効になります",
|
||||
"vpnEnableDesc": "VpnServiceでシステムの全トラフィックを自動的にルーティングします",
|
||||
"options": "オプション",
|
||||
"loopback": "ループバック解除ツール",
|
||||
"loopbackDesc": "UWPループバック解除用",
|
||||
"providers": "プロバイダー",
|
||||
"loopbackDesc": "UWPのループバック解除に使用します",
|
||||
"providers": "外部リソース",
|
||||
"proxyProviders": "プロキシプロバイダー",
|
||||
"ruleProviders": "ルールプロバイダー",
|
||||
"overrideDns": "DNS上書き",
|
||||
"overrideDnsDesc": "有効化するとプロファイルのDNS設定を上書き",
|
||||
"status": "ステータス",
|
||||
"statusDesc": "無効時はシステムDNSを使用",
|
||||
"preferH3Desc": "DOHのHTTP/3を優先使用",
|
||||
"respectRules": "ルール尊重",
|
||||
"respectRulesDesc": "DNS接続がルールに従う(proxy-server-nameserverの設定が必要)",
|
||||
"subscriptionInfo": "サブスクリプション情報",
|
||||
"overrideDns": "DNSを上書き",
|
||||
"overrideDnsDesc": "有効にすると、プロファイル内のDNS設定を上書きします",
|
||||
"status": "状態",
|
||||
"statusDesc": "無効にすると、システムDNSを使用します",
|
||||
"preferH3Desc": "DoHでHTTP/3を優先します",
|
||||
"respectRules": "ルールに従う",
|
||||
"respectRulesDesc": "DNS接続がルールに従います。proxy-server-nameserverの設定が必要です",
|
||||
"dnsMode": "DNSモード",
|
||||
"fakeipRange": "Fakeip範囲",
|
||||
"fakeipFilter": "Fakeipフィルター",
|
||||
"fakeipRange": "Fake-IP範囲",
|
||||
"fakeipFilter": "Fake-IPフィルター",
|
||||
"defaultNameserver": "デフォルトネームサーバー",
|
||||
"defaultNameserverDesc": "DNSサーバーの解決用",
|
||||
"defaultNameserverDesc": "DNSサーバーの名前解決に使用します",
|
||||
"nameserver": "ネームサーバー",
|
||||
"nameserverDesc": "ドメイン解決用",
|
||||
"useHosts": "ホストを使用",
|
||||
"useSystemHosts": "システムホストを使用",
|
||||
"nameserverDesc": "ドメインの名前解決に使用します",
|
||||
"useHosts": "Hostsを使用",
|
||||
"useSystemHosts": "システムのHostsを使用",
|
||||
"nameserverPolicy": "ネームサーバーポリシー",
|
||||
"nameserverPolicyDesc": "対応するネームサーバーポリシーを指定",
|
||||
"nameserverPolicyDesc": "ドメインごとのネームサーバーポリシーを指定します",
|
||||
"proxyNameserver": "プロキシネームサーバー",
|
||||
"proxyNameserverDesc": "プロキシノード解決用ドメイン",
|
||||
"proxyNameserverDesc": "プロキシノードのドメイン解決に使用します",
|
||||
"fallback": "フォールバック",
|
||||
"fallbackDesc": "通常はオフショアDNSを使用",
|
||||
"fallbackDesc": "通常は国外のDNSを使用します",
|
||||
"fallbackFilter": "フォールバックフィルター",
|
||||
"geoipCode": "GeoIPコード",
|
||||
"ipcidr": "IPCIDR",
|
||||
"ipcidr": "IP/CIDR",
|
||||
"domain": "ドメイン",
|
||||
"reset": "リセット",
|
||||
"action_view": "表示/非表示",
|
||||
"action_start": "開始/停止",
|
||||
"action_mode": "モード切替",
|
||||
"action_proxy": "システムプロキシ",
|
||||
"action_tun": "TUN",
|
||||
"actionView": "表示/非表示",
|
||||
"actionStart": "開始/停止",
|
||||
"actionMode": "モード切替",
|
||||
"actionProxy": "システムプロキシ",
|
||||
"actionTun": "TUN",
|
||||
"disclaimer": "免責事項",
|
||||
"disclaimerDesc": "本ソフトウェアは学習交流や科学研究などの非営利目的でのみ使用されます。商用利用は厳禁です。いかなる商用活動も本ソフトウェアとは無関係です。",
|
||||
"agree": "同意",
|
||||
"disclaimerDesc": "本ソフトウェアは、学習・交流や研究などの非商用目的でのみ使用できます。商用目的での使用は固く禁じられています。いかなる商業行為も本ソフトウェアとは一切関係ありません。",
|
||||
"agree": "同意する",
|
||||
"hotkeyManagement": "ホットキー管理",
|
||||
"hotkeyManagementDesc": "キーボードでアプリを制御",
|
||||
"pressKeyboard": "キーボードを押してください",
|
||||
"inputCorrectHotkey": "正しいホットキーを入力",
|
||||
"hotkeyConflict": "ホットキー競合",
|
||||
"hotkeyManagementDesc": "キーボードでアプリを操作します",
|
||||
"pressKeyboard": "キーを押してください",
|
||||
"inputCorrectHotkey": "正しいホットキーを入力してください",
|
||||
"hotkeyConflict": "ホットキーが競合しています",
|
||||
"remove": "削除",
|
||||
"noHotKey": "ホットキーなし",
|
||||
"noNetwork": "ネットワークなし",
|
||||
"ipv6InboundDesc": "IPv6インバウンドを許可",
|
||||
"noHotKey": "ホットキーはまだありません",
|
||||
"noNetwork": "ネットワークがありません",
|
||||
"ipv6InboundDesc": "IPv6インバウンドを許可します",
|
||||
"exportLogs": "ログをエクスポート",
|
||||
"exportSuccess": "エクスポート成功",
|
||||
"exportSuccess": "エクスポートが完了しました",
|
||||
"iconStyle": "アイコンスタイル",
|
||||
"onlyIcon": "アイコンのみ",
|
||||
"stackMode": "スタックモード",
|
||||
"network": "ネットワーク",
|
||||
"networkDesc": "ネットワーク関連設定の変更",
|
||||
"bypassDomain": "バイパスドメイン",
|
||||
"bypassDomainDesc": "システムプロキシ有効時のみ適用",
|
||||
"resetTip": "リセットを確定",
|
||||
"networkDesc": "ネットワーク関連の設定を変更します",
|
||||
"bypassDomain": "除外ドメイン",
|
||||
"bypassDomainDesc": "システムプロキシが有効な場合のみ適用されます",
|
||||
"resetTip": "リセットしてもよろしいですか?",
|
||||
"icon": "アイコン",
|
||||
"noData": "データなし",
|
||||
"fontFamily": "フォントファミリー",
|
||||
"toggle": "トグル",
|
||||
"noData": "データがありません",
|
||||
"fontFamily": "フォント",
|
||||
"toggle": "切り替え",
|
||||
"system": "システム",
|
||||
"routeMode": "ルートモード",
|
||||
"routeMode_bypassPrivate": "プライベートルートをバイパス",
|
||||
"routeMode_config": "設定を使用",
|
||||
"routeModeBypassPrivate": "プライベートアドレスをバイパス",
|
||||
"routeModeConfig": "設定を使用",
|
||||
"routeAddress": "ルートアドレス",
|
||||
"routeAddressDesc": "ルートアドレスを設定",
|
||||
"pleaseInputAdminPassword": "管理者パスワードを入力",
|
||||
"routeAddressDesc": "リッスンするルートアドレスを設定します",
|
||||
"interfaceNameMode": "アウトバウンドインターフェース",
|
||||
"interfaceNameModeClear": "クリア",
|
||||
"interfaceNameModeFollow": "設定に従う",
|
||||
"interfaceNameModeCustom": "カスタム",
|
||||
"interfaceName": "インターフェース名",
|
||||
"interfaceNameDesc": "アウトバウンド接続に使用するネットワークインターフェース名",
|
||||
"copyEnvVar": "環境変数をコピー",
|
||||
"memoryInfo": "メモリ情報",
|
||||
"cancel": "キャンセル",
|
||||
"fileIsUpdate": "ファイルが変更されました。保存しますか?",
|
||||
"profileHasUpdate": "プロファイルが変更されました。自動更新を無効化しますか?",
|
||||
"fileIsUpdate": "ファイルが変更されています。変更を保存しますか?",
|
||||
"profileHasUpdate": "プロファイルが変更されています。自動更新を無効にしますか?",
|
||||
"hasCacheChange": "変更をキャッシュしますか?",
|
||||
"copySuccess": "コピー成功",
|
||||
"copySuccess": "コピーしました",
|
||||
"copyLink": "リンクをコピー",
|
||||
"exportFile": "ファイルをエクスポート",
|
||||
"cacheCorrupt": "キャッシュが破損しています。クリアしますか?",
|
||||
"detectionTip": "サードパーティAPIに依存(参考値)",
|
||||
"listen": "リスン",
|
||||
"detectionTip": "サードパーティAPIに依存しているため、参考値です",
|
||||
"listen": "リッスン",
|
||||
"undo": "元に戻す",
|
||||
"redo": "やり直す",
|
||||
"none": "なし",
|
||||
"basicConfig": "基本設定",
|
||||
"basicConfigDesc": "基本設定をグローバルに変更",
|
||||
"advancedConfig": "高度な設定",
|
||||
"advancedConfigDesc": "多様な設定を提供",
|
||||
"selectedCountTitle": "{count} 項目が選択されています",
|
||||
"basicConfigDesc": "基本設定をグローバルに変更します",
|
||||
"advancedConfig": "詳細設定",
|
||||
"advancedConfigDesc": "多彩な設定項目を提供します",
|
||||
"selectedCountTitle": "{count} 件選択中",
|
||||
"addRule": "ルールを追加",
|
||||
"ruleName": "ルール名",
|
||||
"content": "内容",
|
||||
"subRule": "サブルール",
|
||||
"ruleTarget": "ルール対象",
|
||||
"ruleTarget": "ルールターゲット",
|
||||
"matchTargetTitle": "マッチ先",
|
||||
"matchTarget": "MATCH-TARGET",
|
||||
"followProfile": "プロファイルに従う",
|
||||
"matchTargetDesc": "MATCH-TARGET を対象にしたルールの行き先。既定ではこのプロファイル末尾の MATCH ルールのターゲットを使います。",
|
||||
"selectMatchTarget": "MATCH-TARGET を選択",
|
||||
"sourceIp": "送信元IP",
|
||||
"noResolve": "IPを解決しない",
|
||||
"saveChanges": "変更を保存しますか?",
|
||||
"findProcessModeDesc": "有効化するとパフォーマンスが若干低下します",
|
||||
"tabAnimationDesc": "モバイル表示でのみ有効",
|
||||
"findProcessModeDesc": "有効にすると、パフォーマンスが多少低下します",
|
||||
"tabAnimationDesc": "モバイル表示でのみ有効です",
|
||||
"colorSchemes": "カラースキーム",
|
||||
"palette": "パレット",
|
||||
"tonalSpotScheme": "トーンスポット",
|
||||
"fidelityScheme": "ハイファイデリティー",
|
||||
"tonalSpotScheme": "トーナルスポット",
|
||||
"fidelityScheme": "フィデリティ",
|
||||
"monochromeScheme": "モノクローム",
|
||||
"neutralScheme": "ニュートラル",
|
||||
"vibrantScheme": "ビブラント",
|
||||
"expressiveScheme": "エクスプレッシブ",
|
||||
"contentScheme": "コンテンツテーマ",
|
||||
"contentScheme": "コンテンツ",
|
||||
"rainbowScheme": "レインボー",
|
||||
"fruitSaladScheme": "フルーツサラダ",
|
||||
"developerMode": "デベロッパーモード",
|
||||
"developerModeEnableTip": "デベロッパーモードが有効になりました。",
|
||||
"developerMode": "開発者モード",
|
||||
"developerModeEnableTip": "開発者モードが有効になりました。",
|
||||
"messageTest": "メッセージテスト",
|
||||
"messageTestTip": "これはメッセージです。",
|
||||
"crashTest": "クラッシュテスト",
|
||||
"crashDetected": "クラッシュを検出しました",
|
||||
"crashDetectedTip": "前回の実行中にアプリがクラッシュしました。クラッシュの繰り返しを防ぐため、現在のプロファイルを解除し、設定の自動セットアップをスキップしました。",
|
||||
"crashDetectedTip": "アプリの起動が2回連続で完了しませんでした。クラッシュループを断ち切るため、プロファイル {name} の選択を解除し、今回の自動セットアップをスキップしました。いつでも選択し直せます。",
|
||||
"launchInterrupted": "起動が完了しませんでした",
|
||||
"launchInterruptedTip": "前回、アプリは起動中に予期せず終了しました。今回の自動セットアップはスキップしました。手動で起動して再試行できます。",
|
||||
"clearData": "データを消去",
|
||||
"textScale": "テキストスケーリング",
|
||||
"textScale": "テキストの拡大縮小",
|
||||
"internet": "インターネット",
|
||||
"systemApp": "システムアプリ",
|
||||
"noNetworkApp": "ネットワークなしアプリ",
|
||||
"restoreStrategy": "復元ストラテジー",
|
||||
"restoreStrategy_override": "上書き",
|
||||
"restoreStrategy_compatible": "互換",
|
||||
"noNetworkApp": "ネットワーク不使用アプリ",
|
||||
"restoreStrategy": "復元方式",
|
||||
"restoreStrategyOverride": "上書き",
|
||||
"restoreStrategyCompatible": "互換",
|
||||
"logsTest": "ログテスト",
|
||||
"emptyTip": "{label}は空欄にできません",
|
||||
"emptyTip": "{label}は空にできません",
|
||||
"urlTip": "{label}はURLである必要があります",
|
||||
"numberTip": "{label}は数字でなければなりません",
|
||||
"interval": "インターバル",
|
||||
"existsTip": "現在の{label}は既に存在しています",
|
||||
"deleteTip": "現在の{label}を削除してもよろしいですか?",
|
||||
"deleteMultipTip": "選択された{label}を削除してもよろしいですか?",
|
||||
"nullTip": "まだ{label}はありません",
|
||||
"numberTip": "{label}は数値である必要があります",
|
||||
"interval": "間隔",
|
||||
"existsTip": "{label}はすでに存在します",
|
||||
"maxLengthTip": "{label}は最大{max}文字です",
|
||||
"multipleValuesTip": "複数の値はカンマで区切ってください",
|
||||
"deleteTip": "この{label}を削除してもよろしいですか?",
|
||||
"deleteMultipTip": "選択した{label}を削除してもよろしいですか?",
|
||||
"nullTip": "{label}はまだありません",
|
||||
"script": "スクリプト",
|
||||
"color": "カラー",
|
||||
"rename": "リネーム",
|
||||
"unnamed": "無題",
|
||||
"unnamed": "名称未設定",
|
||||
"pleaseEnterScriptName": "スクリプト名を入力してください",
|
||||
"mixedPort": "混合ポート",
|
||||
"socksPort": "Socksポート",
|
||||
"mixedPort": "Mixedポート",
|
||||
"socksPort": "SOCKSポート",
|
||||
"redirPort": "Redirポート",
|
||||
"tproxyPort": "Tproxyポート",
|
||||
"portTip": "{label} は 1024 から 49151 の間でなければなりません",
|
||||
"tproxyPort": "TProxyポート",
|
||||
"portTip": "{label} は 1024〜49151 の範囲で指定してください",
|
||||
"portConflictTip": "別のポートを入力してください",
|
||||
"import": "インポート",
|
||||
"importFile": "ファイルからインポート",
|
||||
"importUrl": "URLからインポート",
|
||||
"autoSetSystemDns": "オートセットシステムDNS",
|
||||
"details": "{label}詳細",
|
||||
"creationTime": "作成時間",
|
||||
"autoSetSystemDns": "システムDNSを自動設定",
|
||||
"details": "{label}の詳細",
|
||||
"creationTime": "作成日時",
|
||||
"process": "プロセス",
|
||||
"host": "ホスト",
|
||||
"destination": "宛先",
|
||||
"destinationGeoIP": "宛先地理情報",
|
||||
"destinationGeoIP": "宛先GeoIP",
|
||||
"destinationIPASN": "宛先IP ASN",
|
||||
"specialProxy": "特殊プロキシ",
|
||||
"specialRules": "特殊ルール",
|
||||
@@ -372,170 +399,175 @@
|
||||
"connecting": "接続中...",
|
||||
"restartCoreTip": "コアを再起動してもよろしいですか?",
|
||||
"forceRestartCoreTip": "コアを強制再起動してもよろしいですか?",
|
||||
"dnsHijacking": "DNSハイジャッキング",
|
||||
"coreStatus": "コアステータス",
|
||||
"dataCollectionTip": "データ収集説明",
|
||||
"dataCollectionContent": "本アプリはFirebase Crashlyticsを使用してクラッシュ情報を収集し、アプリの安定性を向上させます。\n収集されるデータにはデバイス情報とクラッシュ詳細が含まれますが、個人の機密データは含まれません。\n設定でこの機能を無効にすることができます。",
|
||||
"dnsHijacking": "DNSハイジャック",
|
||||
"coreStatus": "コアの状態",
|
||||
"dataCollectionTip": "データ収集について",
|
||||
"dataCollectionContent": "本アプリは、安定性向上のために Firebase Crashlytics を使用してクラッシュ情報を収集します。\n収集されるデータにはデバイス情報とクラッシュの詳細が含まれますが、個人の機密データは含まれません。\nこの機能は設定で無効にできます。",
|
||||
"crashlytics": "クラッシュ分析",
|
||||
"crashlyticsTip": "有効にすると、アプリがクラッシュした際に機密情報を含まないクラッシュログを自動的にアップロードします",
|
||||
"crashlyticsTip": "有効にすると、アプリのクラッシュ時に機密情報を含まないクラッシュログを自動的にアップロードします",
|
||||
"appendSystemDns": "システムDNSを追加",
|
||||
"appendSystemDnsTip": "設定にシステムDNSを強制的に追加します",
|
||||
"editRule": "ルールを編集",
|
||||
"overrideMode": "上書きモード",
|
||||
"standardModeDesc": "標準モード、基本設定を上書きし、シンプルなルール追加機能を提供",
|
||||
"scriptModeDesc": "スクリプトモード、外部拡張スクリプトを使用し、ワンクリックで設定を上書きする機能を提供",
|
||||
"standardModeDesc": "標準モード:基本設定を上書きし、シンプルなルール追加機能を提供します",
|
||||
"scriptModeDesc": "スクリプトモード:外部の拡張スクリプトを使用し、ワンクリックで設定を上書きします",
|
||||
"addedRules": "追加ルール",
|
||||
"controlGlobalAddedRules": "グローバル追加ルールを制御",
|
||||
"controlGlobalAddedRules": "グローバル追加ルールを管理",
|
||||
"overrideScript": "上書きスクリプト",
|
||||
"goToConfigureScript": "スクリプト設定に移動",
|
||||
"goToConfigureScript": "スクリプト設定へ移動",
|
||||
"editGlobalRules": "グローバルルールを編集",
|
||||
"externalFetch": "外部取得",
|
||||
"confirmForceCrashCore": "コアを強制的にクラッシュさせてもよろしいですか?",
|
||||
"confirmClearAllData": "すべてのデータをクリアしてもよろしいですか?",
|
||||
"confirmForceCrashCore": "コアを強制クラッシュさせてもよろしいですか?",
|
||||
"confirmClearAllData": "すべてのデータを消去してもよろしいですか?",
|
||||
"loading": "読み込み中...",
|
||||
"loadTest": "読み込みテスト",
|
||||
"yearsAgo": "{count}年前",
|
||||
"monthsAgo": "{count}ヶ月前",
|
||||
"daysAgo": "{count}日前",
|
||||
"hoursAgo": "{count}時間前",
|
||||
"minutesAgo": "{count}分前",
|
||||
"yearsAgo": "{count} 年前",
|
||||
"monthsAgo": "{count} か月前",
|
||||
"daysAgo": "{count} 日前",
|
||||
"hoursAgo": "{count} 時間前",
|
||||
"minutesAgo": "{count} 分前",
|
||||
"justNow": "たった今",
|
||||
"noLongerRemind": "今後表示しない",
|
||||
"accessControlSettings": "アクセス制御設定",
|
||||
"turnOn": "オン",
|
||||
"turnOff": "オフ",
|
||||
"vpnConfigChangeDetected": "VPN設定の変更が検出されました",
|
||||
"accessControlSettings": "アクセス制御の設定",
|
||||
"turnOn": "オンにする",
|
||||
"turnOff": "オフにする",
|
||||
"vpnConfigChangeDetected": "VPN関連の設定変更を検出しました",
|
||||
"restart": "再起動",
|
||||
"speedStatistics": "速度統計",
|
||||
"resetPageChangesTip": "現在のページに変更があります。リセットしてもよろしいですか?",
|
||||
"resetPageChangesTip": "このページには変更があります。リセットしてもよろしいですか?",
|
||||
"overwriteTypeCustom": "カスタム",
|
||||
"overwriteTypeCustomDesc": "カスタムモード、プロキシグループとルールを完全にカスタマイズ可能",
|
||||
"overwriteTypeCustomDesc": "カスタムモード:プロキシグループとルールを完全にカスタマイズできます",
|
||||
"unknownNetworkError": "不明なネットワークエラー",
|
||||
"restoreException": "復元例外",
|
||||
"networkException": "ネットワーク例外、接続を確認してもう一度お試しください",
|
||||
"restoreException": "復元エラー",
|
||||
"networkException": "ネットワークエラーです。接続を確認してから再試行してください",
|
||||
"invalidBackupFile": "無効なバックアップファイル",
|
||||
"pruneCache": "キャッシュの削除",
|
||||
"pruneCache": "キャッシュを整理",
|
||||
"backupAndRestore": "バックアップと復元",
|
||||
"backupAndRestoreDesc": "WebDAVまたはファイルを介してデータを同期する",
|
||||
"backupAndRestoreDesc": "WebDAVまたはファイルでデータを同期します",
|
||||
"restore": "復元",
|
||||
"restoreSuccess": "復元に成功しました",
|
||||
"restoreFromWebDAVDesc": "WebDAVを介してデータを復元する",
|
||||
"restoreFromFileDesc": "ファイルを介してデータを復元する",
|
||||
"restoreOnlyConfig": "設定ファイルのみを復元する",
|
||||
"restoreAllData": "すべてのデータを復元する",
|
||||
"restoreSuccess": "復元が完了しました",
|
||||
"restoreFromWebDAVDesc": "WebDAVからデータを復元します",
|
||||
"restoreFromFileDesc": "ファイルからデータを復元します",
|
||||
"restoreOnlyConfig": "プロファイルのみ復元",
|
||||
"restoreAllData": "すべてのデータを復元",
|
||||
"addProfile": "プロファイルを追加",
|
||||
"delayTest": "遅延テスト",
|
||||
"proxyGroupEmpty": "プロキシグループが空です",
|
||||
"proxyGroupNameEmpty": "プロキシグループ名は空にできません",
|
||||
"proxyGroupNameDuplicate": "プロキシグループ名が重複しています",
|
||||
"confirmExitWindow": "現在のウィンドウを閉じてもよろしいですか?",
|
||||
"dataChangedSave": "データの変更が検出されました。保存しますか?",
|
||||
"dataChangedSave": "データの変更を検出しました。保存しますか?",
|
||||
"selectProxyProviders": "プロキシプロバイダーを選択",
|
||||
"proxyFilter": "プロキシフィルター",
|
||||
"optional": "オプション",
|
||||
"proxyFilter": "ノードフィルター",
|
||||
"optional": "任意",
|
||||
"maxFailedTimes": "最大失敗回数",
|
||||
"testInterval": "テスト間隔",
|
||||
"excludeProxyFilter": "除外プロキシフィルター",
|
||||
"excludeProxyFilter": "除外ノードフィルター",
|
||||
"excludeType": "除外タイプ",
|
||||
"expectedStatus": "期待されるステータス",
|
||||
"expectedStatus": "期待するステータス",
|
||||
"selectProxies": "プロキシを選択",
|
||||
"inputProxyGroupName": "プロキシグループ名を入力",
|
||||
"helperCorruptTip": "Helper サービスが利用できないため、TUN モードを有効にできません。再インストールしてください。",
|
||||
"inputProxyGroupName": "プロキシグループ名を入力してください",
|
||||
"helperCorruptTip": "Helper サービスが利用できないため、TUN モードを有効にできません。FlClash を再インストールしてください。",
|
||||
"coreBlockedByPolicyTip": "Windows が FlClashCore.exe の実行を拒否しました(エラー {code})。スマート アプリ コントロールや AppLocker などのアプリ制御ポリシーは未署名のプログラムをブロックします。ポリシーで FlClash を許可するか、ポリシーを無効にしてから再試行してください。",
|
||||
"coreBlockedBySmartAppControlTip": "Windows のスマート アプリ コントロールが、署名されていない FlClashCore.exe をブロックしました。Windows セキュリティ → アプリとブラウザーの制御 → スマート アプリ コントロールの設定で「オフ」を選び、FlClash を再起動してください。一度オフにすると、Windows を再インストールしない限り再度オンにはできません。",
|
||||
"hideFromList": "リストから隠す",
|
||||
"testWhenUsed": "使用時にテスト",
|
||||
"disableUDP": "UDPを無効化",
|
||||
"confirmDeleteProxyGroup": "現在のプロキシグループを削除してもよろしいですか?",
|
||||
"confirmDeleteProxyGroup": "このプロキシグループを削除してもよろしいですか?",
|
||||
"ruleEmpty": "ルールが空です",
|
||||
"inputRuleContent": "ルール内容を入力",
|
||||
"inputRuleContent": "ルールの内容を入力してください",
|
||||
"ruleSet": "ルールセット",
|
||||
"selectRuleSet": "ルールセットを選択してください",
|
||||
"splitStrategy": "分流戦略",
|
||||
"selectSplitStrategy": "分流戦略を選択してください",
|
||||
"splitStrategy": "振り分け戦略",
|
||||
"selectSplitStrategy": "振り分け戦略を選択してください",
|
||||
"selectSubRule": "サブルールを選択してください",
|
||||
"noResolveHostname": "ホスト名を解決しない",
|
||||
"matchSourceIp": "送信元IPをマッチング",
|
||||
"matchSourceIp": "送信元IPにマッチ",
|
||||
"basicInfo": "基本情報",
|
||||
"additionalParameters": "追加パラメータ",
|
||||
"proxyType": "プロキシタイプ",
|
||||
"basicStrategy": "基本戦略",
|
||||
"basicStrategy": "基本ポリシー",
|
||||
"editProxy": "プロキシを編集",
|
||||
"includeAllProxyProviders": "すべてのプロキシプロバイダーを含める",
|
||||
"includeAllProxyProvidersTip": "有効にすると、インポートされたプロキシプロバイダーを上書きします",
|
||||
"includeAllProxyProvidersTip": "有効にすると、取り込んだプロキシプロバイダーを上書きします",
|
||||
"addProxyProviders": "プロキシプロバイダーを追加",
|
||||
"includeAllProxies": "すべてのプロキシを含める",
|
||||
"includeAllProxiesTip": "プロキシグループに含まれないすべてのプロキシをインポートします。下でさらにプロキシグループを追加できます",
|
||||
"includeAllProxiesTip": "プロキシグループに属さないすべてのプロキシを取り込みます。下でプロキシグループを追加できます",
|
||||
"proxiesEmpty": "プロキシが空です",
|
||||
"addProxies": "プロキシを追加",
|
||||
"addProxyGroup": "プロキシグループを追加",
|
||||
"editProxyGroup": "プロキシグループを編集",
|
||||
"confirmOverwriteTip": "確認後、既存のデータは上書きされます",
|
||||
"configDataDetected": "設定内にデータが検出されました",
|
||||
"confirmOverwriteTip": "確定すると既存のデータを上書きします",
|
||||
"configDataDetected": "設定内にデータが見つかりました",
|
||||
"quickFill": "クイック入力",
|
||||
"iconUrl": "アイコンURL",
|
||||
"iconRecords": "アイコン履歴",
|
||||
"noRecords": "履歴なし",
|
||||
"noRecords": "記録がありません",
|
||||
"custom": "カスタム",
|
||||
"ruleActionDomainDesc": "完全なドメインをマッチング",
|
||||
"ruleActionDomainSuffixDesc": "ドメイン接尾辞をマッチング",
|
||||
"ruleActionDomainKeywordDesc": "ドメインキーワードをマッチング",
|
||||
"ruleActionDomainRegexDesc": "ワイルドカードマッチング(*と?のみサポート)",
|
||||
"ruleActionGeositeDesc": "Match domains within Geosite",
|
||||
"ruleActionIpCidrDesc": "IPアドレス範囲をマッチング",
|
||||
"ruleActionIpCidr6Desc": "IPアドレス範囲をマッチング(IP-CIDR6はエイリアスです)",
|
||||
"ruleActionIpSuffixDesc": "IP接尾辞範囲をマッチング",
|
||||
"ruleActionIpAsnDesc": "IPのASNをマッチング",
|
||||
"ruleActionGeoipDesc": "IPの国コードをマッチング",
|
||||
"ruleActionSrcGeoipDesc": "送信元IPの国コードをマッチング",
|
||||
"ruleActionSrcIpAsnDesc": "送信元IPのASNをマッチング",
|
||||
"ruleActionSrcIpCidrDesc": "送信元IPアドレス範囲をマッチング",
|
||||
"ruleActionSrcIpSuffixDesc": "送信元IP接尾辞範囲をマッチング",
|
||||
"ruleActionDstPortDesc": "宛先ポート範囲をマッチング",
|
||||
"ruleActionSrcPortDesc": "送信元ポート範囲をマッチング",
|
||||
"ruleActionInPortDesc": "インバウンドポートをマッチング",
|
||||
"ruleActionInTypeDesc": "インバウンドタイプをマッチング",
|
||||
"ruleActionInUserDesc": "インバウンドユーザー名をマッチング(/で複数指定可)",
|
||||
"ruleActionInNameDesc": "インバウンド名をマッチング",
|
||||
"ruleActionProcessPathDesc": "フルプロセスパスでマッチング",
|
||||
"ruleActionProcessPathRegexDesc": "プロセスパス正規表現でマッチング",
|
||||
"ruleActionProcessNameDesc": "プロセス名でマッチング(Androidではパッケージ名)",
|
||||
"ruleActionProcessNameRegexDesc": "プロセス名正規表現でマッチング(Androidではパッケージ名)",
|
||||
"ruleActionUidDesc": "Linux USER IDをマッチング",
|
||||
"ruleActionNetworkDesc": "TCPまたはUDPをマッチング",
|
||||
"ruleActionDscpDesc": "DSCPマークをマッチング (tproxy udp inboundのみ)",
|
||||
"ruleActionRuleSetDesc": "ルールセットを参照。rule-providersの設定が必要",
|
||||
"ruleActionDomainDesc": "完全なドメインにマッチ",
|
||||
"ruleActionDomainSuffixDesc": "ドメインサフィックスにマッチ",
|
||||
"ruleActionDomainKeywordDesc": "ドメインキーワードにマッチ",
|
||||
"ruleActionDomainRegexDesc": "ドメインの正規表現でマッチ",
|
||||
"ruleActionDomainWildcardDesc": "ワイルドカードでマッチ(* と ? のみ対応)",
|
||||
"ruleActionGeositeDesc": "Geosite 内のドメインにマッチ",
|
||||
"ruleActionIpCidrDesc": "IPアドレス範囲にマッチ",
|
||||
"ruleActionIpCidr6Desc": "IPアドレス範囲にマッチ(IP-CIDR6 は別名です)",
|
||||
"ruleActionIpSuffixDesc": "IPサフィックス範囲にマッチ",
|
||||
"ruleActionIpAsnDesc": "IPが属するASNにマッチ",
|
||||
"ruleActionGeoipDesc": "IPの国コードにマッチ",
|
||||
"ruleActionSrcGeoipDesc": "送信元IPの国コードにマッチ",
|
||||
"ruleActionSrcIpAsnDesc": "送信元IPが属するASNにマッチ",
|
||||
"ruleActionSrcIpCidrDesc": "送信元IPアドレス範囲にマッチ",
|
||||
"ruleActionSrcIpSuffixDesc": "送信元IPサフィックス範囲にマッチ",
|
||||
"ruleActionDstPortDesc": "宛先ポート範囲にマッチ",
|
||||
"ruleActionSrcPortDesc": "送信元ポート範囲にマッチ",
|
||||
"ruleActionInPortDesc": "インバウンドポートにマッチ",
|
||||
"ruleActionInTypeDesc": "インバウンドタイプにマッチ",
|
||||
"ruleActionInUserDesc": "インバウンドユーザー名にマッチ(/ で複数指定可)",
|
||||
"ruleActionInNameDesc": "インバウンド名にマッチ",
|
||||
"ruleActionRematchNameDesc": "再マッチ名にマッチ(複数は / で区切る)",
|
||||
"ruleActionProcessPathDesc": "プロセスのフルパスでマッチ",
|
||||
"ruleActionProcessPathRegexDesc": "プロセスパスの正規表現でマッチ",
|
||||
"ruleActionProcessPathWildcardDesc": "プロセスパスのワイルドカードでマッチ(* と ? のみ対応)",
|
||||
"ruleActionProcessNameDesc": "プロセス名でマッチ(Androidではパッケージ名にマッチ)",
|
||||
"ruleActionProcessNameRegexDesc": "プロセス名の正規表現でマッチ(Androidではパッケージ名にマッチ)",
|
||||
"ruleActionProcessNameWildcardDesc": "プロセス名のワイルドカードでマッチ(* と ? のみ対応)",
|
||||
"ruleActionUidDesc": "LinuxのユーザーIDにマッチ",
|
||||
"ruleActionNetworkDesc": "TCPまたはUDPにマッチ",
|
||||
"ruleActionDscpDesc": "DSCPマークにマッチ(tproxy udpインバウンドのみ)",
|
||||
"ruleActionRuleSetDesc": "ルールセットを参照します。rule-providersの設定が必要です",
|
||||
"ruleActionAndDesc": "論理ルール AND",
|
||||
"ruleActionOrDesc": "論理ルール OR",
|
||||
"ruleActionNotDesc": "論理ルール NOT",
|
||||
"ruleActionSubRuleDesc": "サブルールにマッチング。括弧の使用に注意",
|
||||
"ruleActionMatchDesc": "すべてのリクエストにマッチ(条件なし)",
|
||||
"ruleActionSubRuleDesc": "サブルールへマッチします。括弧の使い方に注意してください",
|
||||
"ruleActionMatchDesc": "すべてのリクエストにマッチ(条件不要)",
|
||||
"subRuleEmpty": "サブルールが空です",
|
||||
"proxyProvidersNotEmpty": "プロキシプロバイダーは空にできません",
|
||||
"contentNotEmpty": "内容は空にできません",
|
||||
"subRuleNotEmpty": "サブルールは空にできません",
|
||||
"splitStrategyNotEmpty": "分流戦略は空にできません",
|
||||
"splitStrategyNotEmpty": "振り分け戦略は空にできません",
|
||||
"proxyProvidersEmpty": "プロキシプロバイダーが空です",
|
||||
"timeout": "タイムアウト",
|
||||
"invalidSubRule": "{subRule} は無効なSUB_RULEです",
|
||||
"invalidSubRule": "{subRule} は無効な SUB_RULE です",
|
||||
"invalidPolicy": "{target} は無効なポリシーです",
|
||||
"invalidProxyProvider": "{providerName} は無効なプロキシプロバイダーです",
|
||||
"invalidProxy": "{proxyName} は無効なプロキシです",
|
||||
"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",
|
||||
"proxyGroupDetectedAbnormal": "現在のプロキシグループに異常が見つかりました",
|
||||
"proxyProviderDetectedAbnormal": "選択したプロキシプロバイダーに異常が見つかりました",
|
||||
"proxyDetectedAbnormal": "選択したプロキシに異常が見つかりました",
|
||||
"createProfile": "プロファイルを作成",
|
||||
"locationPermissionRequired": "位置情報の権限が必要です",
|
||||
"locationPermissionGuide": "1. システム設定 > プライバシーとセキュリティ を開く\n2. 位置情報サービス を選択\n3. リストで {appName} を見つけてチェックを入れる\n\n設定が完了したらアプリに戻ると、通常どおり使用できます。ご協力ありがとうございます。",
|
||||
"prerequisites": "前提条件",
|
||||
"ignoreBatteryOptimization": "電池の最適化を無視",
|
||||
"batteryOptimizationDesc": "バックグラウンドでの動作を維持するため、このアプリの電池の最適化を無効にしてください。タップすると設定を開きます。",
|
||||
"batteryOptimizationStatusTip": "システムの制限により、実行中は電池の最適化の状態を正しく取得できません",
|
||||
"locationPermission": "位置情報の権限",
|
||||
"locationPermissionDesc": "システムの要件により、Wi-Fi 名の取得には位置情報の権限が必要です。Android では「常に許可」を選択してください。そうしないと、アプリがバックグラウンドにあるときに Wi-Fi 名を取得できません。",
|
||||
"excludeSsids": "除外SSID",
|
||||
"excludeSsidsDesc": "除外したSSIDのWi-Fiに接続すると、アプリの実行状態が自動的に切り替わります",
|
||||
"ssidsEmpty": "SSIDが空です",
|
||||
"onDemand": "オンデマンド",
|
||||
"onDemandDesc": "特定のシナリオでのアプリの実行状態を設定します",
|
||||
"locationPermissionDeniedMessage": "位置情報の権限が拒否されたため、現在の Wi-Fi 名を取得できません。システム設定で位置情報の権限を手動で有効にしてください。",
|
||||
"addSsid": "SSIDを追加",
|
||||
"editSsid": "SSIDを編集",
|
||||
@@ -545,13 +577,45 @@
|
||||
"geoOptions": "Geoオプション",
|
||||
"geoAutoUpdate": "自動更新",
|
||||
"geoAutoUpdateInterval": "自動更新間隔",
|
||||
"geoAutoUpdateIntervalTip": "自動更新間隔は0より大きくなければなりません",
|
||||
"geoAutoUpdateIntervalTip": "自動更新間隔は0より大きくしてください",
|
||||
"hours": "時間",
|
||||
"hoursCount": "{count} 時間",
|
||||
"geoResources": "Geoリソース",
|
||||
"geoUpdating": "{name}を更新中...",
|
||||
"geoSkipped": "{name} はすでに最新です",
|
||||
"geoUpdated": "{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": "固定を解除",
|
||||
"exitFullScreen": "全画面表示を終了",
|
||||
"toggleLabel": "ラベルを切り替え",
|
||||
"torch": "ライト",
|
||||
"pickFromAlbum": "アルバムから選択",
|
||||
"blockConnection": "接続をブロック",
|
||||
"closeConnections": "接続を閉じる",
|
||||
"scrollToSelected": "選択項目へスクロール",
|
||||
"showMore": "展開",
|
||||
"showLess": "折りたたむ",
|
||||
"previousMatch": "前の一致",
|
||||
"nextMatch": "次の一致",
|
||||
"clearSearch": "検索をクリア",
|
||||
"addWidget": "ウィジェットを追加",
|
||||
"showPassword": "パスワードを表示",
|
||||
"hidePassword": "パスワードを隠す",
|
||||
"authorize": "許可",
|
||||
"installedAppsPermissionRequired": "アプリ一覧の権限が必要です",
|
||||
"installedAppsPermissionDesc": "このシステムでは、許可するまでインストール済みアプリの一覧が提供されません。許可すると、アプリごとのプロキシを設定できます。",
|
||||
"installedAppsPermissionDeniedMessage": "アプリ一覧の権限が拒否されたため、インストール済みアプリを取得できません。システム設定から手動で許可してください。"
|
||||
}
|
||||
|
||||
@@ -1,97 +1,103 @@
|
||||
{
|
||||
"rule": "Правило",
|
||||
"rules": "Правила",
|
||||
"global": "Глобальный",
|
||||
"direct": "Прямой",
|
||||
"dashboard": "Панель управления",
|
||||
"dashboard": "Панель",
|
||||
"proxies": "Прокси",
|
||||
"profile": "Профиль",
|
||||
"profiles": "Профили",
|
||||
"tools": "Инструменты",
|
||||
"logs": "Логи",
|
||||
"logsDesc": "Записи захвата логов",
|
||||
"logsDesc": "Записи захваченных логов",
|
||||
"resources": "Ресурсы",
|
||||
"resourcesDesc": "Информация, связанная с внешними ресурсами",
|
||||
"trafficUsage": "Использование трафика",
|
||||
"resourcesDesc": "Сведения о внешних ресурсах",
|
||||
"trafficUsage": "Статистика трафика",
|
||||
"networkSpeed": "Скорость сети",
|
||||
"outboundMode": "Режим исходящего трафика",
|
||||
"networkDetection": "Обнаружение сети",
|
||||
"upload": "Загрузка",
|
||||
"download": "Скачивание",
|
||||
"nullProfileDesc": "Нет профиля, пожалуйста, добавьте профиль",
|
||||
"networkDetection": "Проверка сети",
|
||||
"upload": "Отдача",
|
||||
"download": "Загрузка",
|
||||
"usedTraffic": "Использованный трафик",
|
||||
"totalTraffic": "Общий трафик",
|
||||
"expireTime": "Срок действия",
|
||||
"nullProfileDesc": "Профилей пока нет. Сначала добавьте профиль",
|
||||
"settings": "Настройки",
|
||||
"language": "Язык",
|
||||
"defaultText": "По умолчанию",
|
||||
"more": "Еще",
|
||||
"more": "Ещё",
|
||||
"other": "Другое",
|
||||
"about": "О программе",
|
||||
"en": "Английский",
|
||||
"ja": "Японский",
|
||||
"ru": "Русский",
|
||||
"zh_CN": "Упрощенный китайский",
|
||||
"zhCN": "Упрощённый китайский",
|
||||
"theme": "Тема",
|
||||
"themeDesc": "Установить темный режим, настроить цвет",
|
||||
"override": "Переопределить",
|
||||
"themeDesc": "Тёмный режим и настройка цветов",
|
||||
"override": "Переопределение",
|
||||
"allowLan": "Разрешить LAN",
|
||||
"allowLanDesc": "Разрешить доступ к прокси через локальную сеть",
|
||||
"allowLanDesc": "Разрешить доступ к прокси из локальной сети",
|
||||
"tun": "TUN",
|
||||
"tunDesc": "действительно только в режиме администратора",
|
||||
"minimizeOnExit": "Свернуть при выходе",
|
||||
"minimizeOnExitDesc": "Изменить стандартное событие выхода из системы",
|
||||
"tunDesc": "Работает только в режиме администратора",
|
||||
"minimizeOnExit": "Сворачивать при выходе",
|
||||
"minimizeOnExitDesc": "Изменяет стандартное поведение при выходе",
|
||||
"autoLaunch": "Автозапуск",
|
||||
"autoLaunchDesc": "Следовать автозапуску системы",
|
||||
"autoLaunchDesc": "Запускаться автоматически при старте системы",
|
||||
"silentLaunch": "Тихий запуск",
|
||||
"silentLaunchDesc": "Запуск в фоновом режиме",
|
||||
"autoRun": "Автозапуск",
|
||||
"autoRunDesc": "Автоматический запуск при открытии приложения",
|
||||
"logcat": "Logcat",
|
||||
"logcatDesc": "Отключение скроет запись логов",
|
||||
"silentLaunchDesc": "Запускаться в фоновом режиме",
|
||||
"autoRun": "Автовключение",
|
||||
"autoRunDesc": "Включаться автоматически при открытии приложения",
|
||||
"logcat": "Захват логов",
|
||||
"logcatDesc": "При отключении раздел логов будет скрыт",
|
||||
"autoCheckUpdate": "Автопроверка обновлений",
|
||||
"autoCheckUpdateDesc": "Автоматически проверять обновления при запуске приложения",
|
||||
"checkCertificate": "Проверять TLS-сертификаты",
|
||||
"checkCertificateDesc": "Отклонять недоверенные сертификаты. Отключение подвергает подписки и резервные копии атаке «человек посередине»",
|
||||
"accessControl": "Контроль доступа",
|
||||
"accessControlDesc": "Настройка доступа приложений к прокси",
|
||||
"accessControlDesc": "Выбор приложений, использующих прокси",
|
||||
"application": "Приложение",
|
||||
"applicationDesc": "Изменение настроек, связанных с приложением",
|
||||
"applicationDesc": "Настройки, связанные с приложением",
|
||||
"edit": "Редактировать",
|
||||
"confirm": "Подтвердить",
|
||||
"update": "Обновить",
|
||||
"add": "Добавить",
|
||||
"save": "Сохранить",
|
||||
"delete": "Удалить",
|
||||
"seconds": "Секунд",
|
||||
"seconds": "секунд",
|
||||
"qrcode": "QR-код",
|
||||
"qrcodeDesc": "Сканируйте QR-код для получения профиля",
|
||||
"qrcodeDesc": "Сканируйте QR-код, чтобы получить профиль",
|
||||
"url": "URL",
|
||||
"urlDesc": "Получить профиль через URL",
|
||||
"urlDesc": "Получить профиль по URL",
|
||||
"file": "Файл",
|
||||
"fileDesc": "Прямая загрузка профиля",
|
||||
"name": "Имя",
|
||||
"profileNameNullValidationDesc": "Пожалуйста, введите имя профиля",
|
||||
"profileUrlNullValidationDesc": "Пожалуйста, введите URL профиля",
|
||||
"profileUrlInvalidValidationDesc": "Пожалуйста, введите действительный URL профиля",
|
||||
"fileDesc": "Загрузить файл профиля напрямую",
|
||||
"name": "Название",
|
||||
"profileNameNullValidationDesc": "Введите название профиля",
|
||||
"profileUrlNullValidationDesc": "Введите URL профиля",
|
||||
"profileUrlInvalidValidationDesc": "Введите корректный URL профиля",
|
||||
"autoUpdate": "Автообновление",
|
||||
"autoUpdateInterval": "Интервал автообновления (минуты)",
|
||||
"profileAutoUpdateIntervalNullValidationDesc": "Пожалуйста, введите интервал времени для автообновления",
|
||||
"profileAutoUpdateIntervalInvalidValidationDesc": "Пожалуйста, введите действительный формат интервала времени",
|
||||
"profileAutoUpdateIntervalNullValidationDesc": "Введите интервал автообновления",
|
||||
"profileAutoUpdateIntervalInvalidValidationDesc": "Введите корректный интервал",
|
||||
"themeMode": "Режим темы",
|
||||
"themeColor": "Цвет темы",
|
||||
"preview": "Предпросмотр",
|
||||
"auto": "Авто",
|
||||
"light": "Светлый",
|
||||
"dark": "Темный",
|
||||
"light": "Светлая",
|
||||
"dark": "Тёмная",
|
||||
"importFromURL": "Импорт из URL",
|
||||
"submit": "Отправить",
|
||||
"doYouWantToPass": "Вы хотите пропустить",
|
||||
"createProfileFromUrlTip": "Создать профиль по ссылке {url}?",
|
||||
"create": "Создать",
|
||||
"pleaseUploadValidQrcode": "Пожалуйста, загрузите действительный QR-код",
|
||||
"blacklistMode": "Режим черного списка",
|
||||
"pleaseUploadValidQrcode": "Загрузите корректный QR-код",
|
||||
"blacklistMode": "Режим чёрного списка",
|
||||
"whitelistMode": "Режим белого списка",
|
||||
"selectAll": "Выбрать все",
|
||||
"cancelSelectAll": "Отменить выбор всего",
|
||||
"selectAll": "Выбрать всё",
|
||||
"cancelSelectAll": "Снять выделение",
|
||||
"appAccessControl": "Контроль доступа приложений",
|
||||
"accessControlAllowDesc": "Разрешить только выбранным приложениям доступ к VPN",
|
||||
"accessControlNotAllowDesc": "Выбранные приложения будут исключены из VPN",
|
||||
"accessControlAllowDesc": "Через VPN проходят только выбранные приложения",
|
||||
"accessControlNotAllowDesc": "Выбранные приложения исключаются из VPN",
|
||||
"accessControlDisabledDesc": "Контроль доступа приложений отключён",
|
||||
"selected": "Выбрано",
|
||||
"proxyPort": "Порт прокси",
|
||||
"port": "Порт",
|
||||
"logLevel": "Уровень логов",
|
||||
"show": "Показать",
|
||||
@@ -100,66 +106,72 @@
|
||||
"project": "Проект",
|
||||
"core": "Ядро",
|
||||
"tabAnimation": "Анимация вкладок",
|
||||
"desc": "Многоплатформенный прокси-клиент на основе ClashMeta, простой и удобный в использовании, с открытым исходным кодом и без рекламы.",
|
||||
"desc": "Многоплатформенный прокси-клиент на основе ClashMeta: простой и удобный, с открытым исходным кодом и без рекламы.",
|
||||
"startVpn": "Запуск VPN...",
|
||||
"stopVpn": "Остановка VPN...",
|
||||
"compatible": "Режим совместимости",
|
||||
"notSelectedTip": "Текущая группа прокси не может быть выбрана.",
|
||||
"tip": "подсказка",
|
||||
"notSelectedTip": "Текущую группу прокси нельзя выбрать",
|
||||
"changeProxyFailedTip": "Не удалось переключить прокси; восстановлен предыдущий выбор",
|
||||
"databaseWriteFailedTip": "Не удалось сохранить изменение; оно отменено",
|
||||
"tip": "Подсказка",
|
||||
"account": "Аккаунт",
|
||||
"backup": "Резервное копирование",
|
||||
"backupSuccess": "Резервное копирование успешно",
|
||||
"backupSuccess": "Резервная копия создана",
|
||||
"noInfo": "Нет информации",
|
||||
"pleaseBindWebDAV": "Пожалуйста, привяжите WebDAV",
|
||||
"pleaseBindWebDAV": "Привяжите WebDAV",
|
||||
"bind": "Привязать",
|
||||
"connectivity": "Связь:",
|
||||
"webDAVConfiguration": "Конфигурация WebDAV",
|
||||
"connectivity": "Подключение: ",
|
||||
"webDAVConfiguration": "Настройка WebDAV",
|
||||
"address": "Адрес",
|
||||
"addressHelp": "Адрес сервера WebDAV",
|
||||
"addressTip": "Пожалуйста, введите действительный адрес WebDAV",
|
||||
"addressTip": "Введите корректный адрес WebDAV",
|
||||
"password": "Пароль",
|
||||
"authentication": "Аутентификация",
|
||||
"authenticationDesc": "Требовать учётные данные для локального порта прокси, чтобы другие приложения не могли использовать его",
|
||||
"authenticationSystemProxyDesc": "Не применяется, пока включена аутентификация",
|
||||
"checkUpdate": "Проверить обновления",
|
||||
"discoverNewVersion": "Обнаружена новая версия",
|
||||
"checkUpdateError": "Текущее приложение уже является последней версией",
|
||||
"goDownload": "Перейти к загрузке",
|
||||
"discoverNewVersion": "Доступна новая версия",
|
||||
"checkUpdateError": "У вас уже последняя версия",
|
||||
"goDownload": "Скачать",
|
||||
"unknown": "Неизвестно",
|
||||
"country": "Страна",
|
||||
"country": "Регион",
|
||||
"search": "Поиск",
|
||||
"allowBypass": "Разрешить приложениям обходить VPN",
|
||||
"allowBypassDesc": "Некоторые приложения могут обходить VPN при включении",
|
||||
"allowBypassDesc": "При включении некоторые приложения смогут обходить VPN",
|
||||
"externalController": "Внешний контроллер",
|
||||
"externalControllerDesc": "При включении ядро Clash можно контролировать на порту 9090",
|
||||
"ipv6Desc": "При включении будет возможно получать IPv6 трафик",
|
||||
"externalControllerDesc": "При включении ядром Clash можно управлять через порт 9090",
|
||||
"ipv6Desc": "При включении можно принимать трафик IPv6",
|
||||
"app": "Приложение",
|
||||
"general": "Общие",
|
||||
"systemProxyDesc": "Прикрепить HTTP-прокси к VpnService",
|
||||
"systemProxyDesc": "Настроить системный прокси",
|
||||
"userAgent": "User-Agent",
|
||||
"unifiedDelay": "Унифицированная задержка",
|
||||
"unifiedDelayDesc": "Убрать дополнительные задержки, такие как рукопожатие",
|
||||
"tcpConcurrent": "TCP параллелизм",
|
||||
"tcpConcurrentDesc": "Включение позволит использовать параллелизм TCP",
|
||||
"geodataLoader": "Режим низкого потребления памяти для геоданных",
|
||||
"geodataLoaderDesc": "Включение будет использовать загрузчик геоданных с низким потреблением памяти",
|
||||
"unifiedDelay": "Единая задержка",
|
||||
"unifiedDelayDesc": "Убирает лишние задержки, например рукопожатие",
|
||||
"tcpConcurrent": "Параллельный TCP",
|
||||
"tcpConcurrentDesc": "При включении разрешает параллельные TCP-подключения",
|
||||
"geodataLoader": "Geo: экономия памяти",
|
||||
"geodataLoaderDesc": "При включении используется Geo-загрузчик с низким потреблением памяти",
|
||||
"requests": "Запросы",
|
||||
"requestsDesc": "Просмотр последних записей запросов",
|
||||
"findProcessMode": "Режим поиска процесса",
|
||||
"requestsDesc": "Просмотр последних запросов",
|
||||
"findProcessMode": "Поиск процесса",
|
||||
"init": "Инициализация",
|
||||
"infiniteTime": "Долгосрочное действие",
|
||||
"infiniteTime": "Бессрочно",
|
||||
"connections": "Соединения",
|
||||
"connectionsDesc": "Просмотр текущих данных о соединениях",
|
||||
"inbound": "Входящие",
|
||||
"connectionsDesc": "Просмотр данных о текущих соединениях",
|
||||
"intranetIP": "Внутренний IP",
|
||||
"view": "Просмотр",
|
||||
"cut": "Вырезать",
|
||||
"copy": "Копировать",
|
||||
"paste": "Вставить",
|
||||
"testUrl": "Тест URL",
|
||||
"testUrl": "URL для теста",
|
||||
"sync": "Синхронизация",
|
||||
"exclude": "Скрыть из последних задач",
|
||||
"excludeDesc": "Когда приложение находится в фоновом режиме, оно скрыто из последних задач",
|
||||
"exclude": "Скрыть из недавних задач",
|
||||
"excludeDesc": "Скрывать приложение из недавних задач, когда оно в фоне",
|
||||
"expand": "Стандартный",
|
||||
"shrink": "Сжать",
|
||||
"min": "Мин",
|
||||
"tab": "Вкладка",
|
||||
"shrink": "Компактный",
|
||||
"min": "Минимальный",
|
||||
"tab": "Вкладки",
|
||||
"list": "Список",
|
||||
"delay": "Задержка",
|
||||
"style": "Стиль",
|
||||
@@ -170,22 +182,24 @@
|
||||
"go": "Перейти",
|
||||
"externalLink": "Внешняя ссылка",
|
||||
"otherContributors": "Другие участники",
|
||||
"autoCloseConnections": "Автоматическое закрытие соединений",
|
||||
"autoCloseConnections": "Автозакрытие соединений",
|
||||
"autoCloseConnectionsDesc": "Автоматически закрывать соединения после смены узла",
|
||||
"onlyStatisticsProxy": "Только статистика прокси",
|
||||
"onlyStatisticsProxyDesc": "При включении будет учитываться только трафик прокси",
|
||||
"pureBlackMode": "Чисто черный режим",
|
||||
"keepAliveIntervalDesc": "Интервал поддержания TCP-соединения",
|
||||
"onlyStatisticsProxy": "Учитывать только прокси",
|
||||
"onlyStatisticsProxyDesc": "При включении учитывается только трафик через прокси",
|
||||
"showNotificationStopAction": "Кнопка остановки в уведомлении",
|
||||
"showNotificationStopActionDesc": "Показывать кнопку остановки в постоянном уведомлении. Отключите, если из-за неё система всегда разворачивает уведомление",
|
||||
"pureBlackMode": "Чисто чёрный режим",
|
||||
"keepAliveIntervalDesc": "Интервал TCP keep-alive",
|
||||
"entries": " записей",
|
||||
"local": "Локальный",
|
||||
"remote": "Удаленный",
|
||||
"remoteBackupDesc": "Резервное копирование локальных данных на WebDAV",
|
||||
"localBackupDesc": "Резервное копирование локальных данных на локальный диск",
|
||||
"local": "Локально",
|
||||
"remote": "Удалённо",
|
||||
"remoteBackupDesc": "Резервное копирование данных в WebDAV",
|
||||
"localBackupDesc": "Резервное копирование данных локально",
|
||||
"mode": "Режим",
|
||||
"time": "Время",
|
||||
"source": "Источник",
|
||||
"action": "Действие",
|
||||
"intelligentSelected": "Интеллектуальный выбор",
|
||||
"intelligentSelected": "Умный выбор",
|
||||
"clipboardImport": "Импорт из буфера обмена",
|
||||
"clipboardExport": "Экспорт в буфер обмена",
|
||||
"layout": "Макет",
|
||||
@@ -195,176 +209,189 @@
|
||||
"profilesSort": "Сортировка профилей",
|
||||
"start": "Старт",
|
||||
"stop": "Стоп",
|
||||
"dnsDesc": "Обновление настроек, связанных с DNS",
|
||||
"dnsDesc": "Настройки, связанные с DNS",
|
||||
"key": "Ключ",
|
||||
"value": "Значение",
|
||||
"hostsDesc": "Добавить Hosts",
|
||||
"hostsDesc": "Добавить записи hosts",
|
||||
"vpnTip": "Изменения вступят в силу после перезапуска VPN",
|
||||
"vpnEnableDesc": "Автоматически направляет весь системный трафик через VpnService",
|
||||
"options": "Опции",
|
||||
"loopback": "Инструмент разблокировки Loopback",
|
||||
"loopbackDesc": "Используется для разблокировки Loopback UWP",
|
||||
"providers": "Провайдеры",
|
||||
"loopback": "Инструмент разблокировки loopback",
|
||||
"loopbackDesc": "Для снятия ограничения loopback у UWP-приложений",
|
||||
"providers": "Внешние ресурсы",
|
||||
"proxyProviders": "Провайдеры прокси",
|
||||
"ruleProviders": "Провайдеры правил",
|
||||
"subscriptionInfo": "Информация о подписке",
|
||||
"overrideDns": "Переопределить DNS",
|
||||
"overrideDnsDesc": "Включение переопределит настройки DNS в профиле",
|
||||
"overrideDnsDesc": "При включении настройки DNS профиля переопределяются",
|
||||
"status": "Статус",
|
||||
"statusDesc": "Системный DNS будет использоваться при выключении",
|
||||
"preferH3Desc": "Приоритетное использование HTTP/3 для DOH",
|
||||
"respectRules": "Соблюдение правил",
|
||||
"respectRulesDesc": "DNS-соединение следует правилам, необходимо настроить proxy-server-nameserver",
|
||||
"statusDesc": "При отключении используется системный DNS",
|
||||
"preferH3Desc": "Предпочитать HTTP/3 для DoH",
|
||||
"respectRules": "Соблюдать правила",
|
||||
"respectRulesDesc": "DNS-соединения следуют правилам; требуется настроить proxy-server-nameserver",
|
||||
"dnsMode": "Режим DNS",
|
||||
"fakeipRange": "Диапазон Fakeip",
|
||||
"fakeipFilter": "Фильтр Fakeip",
|
||||
"defaultNameserver": "Сервер имен по умолчанию",
|
||||
"defaultNameserverDesc": "Для разрешения DNS-сервера",
|
||||
"nameserver": "Сервер имен",
|
||||
"nameserverDesc": "Для разрешения домена",
|
||||
"fakeipRange": "Диапазон Fake-IP",
|
||||
"fakeipFilter": "Фильтр Fake-IP",
|
||||
"defaultNameserver": "DNS-сервер по умолчанию",
|
||||
"defaultNameserverDesc": "Используется для разрешения адресов DNS-серверов",
|
||||
"nameserver": "DNS-сервер",
|
||||
"nameserverDesc": "Используется для разрешения доменов",
|
||||
"useHosts": "Использовать hosts",
|
||||
"useSystemHosts": "Использовать системные hosts",
|
||||
"nameserverPolicy": "Политика сервера имен",
|
||||
"nameserverPolicyDesc": "Указать соответствующую политику сервера имен",
|
||||
"proxyNameserver": "Прокси-сервер имен",
|
||||
"proxyNameserverDesc": "Домен для разрешения прокси-узлов",
|
||||
"fallback": "Резервный",
|
||||
"fallbackDesc": "Обычно используется оффшорный DNS",
|
||||
"fallbackFilter": "Фильтр резервного DNS",
|
||||
"geoipCode": "Код Geoip",
|
||||
"ipcidr": "IPCIDR",
|
||||
"useSystemHosts": "Использовать системный hosts",
|
||||
"nameserverPolicy": "Политика DNS-серверов",
|
||||
"nameserverPolicyDesc": "Задать политику DNS-серверов для доменов",
|
||||
"proxyNameserver": "DNS-сервер для прокси",
|
||||
"proxyNameserverDesc": "Используется для разрешения доменов прокси-узлов",
|
||||
"fallback": "Fallback",
|
||||
"fallbackDesc": "Обычно зарубежный DNS",
|
||||
"fallbackFilter": "Фильтр fallback",
|
||||
"geoipCode": "Код GeoIP",
|
||||
"ipcidr": "IP/CIDR",
|
||||
"domain": "Домен",
|
||||
"reset": "Сброс",
|
||||
"action_view": "Показать/Скрыть",
|
||||
"action_start": "Старт/Стоп",
|
||||
"action_mode": "Переключить режим",
|
||||
"action_proxy": "Системный прокси",
|
||||
"action_tun": "TUN",
|
||||
"actionView": "Показать/Скрыть",
|
||||
"actionStart": "Старт/Стоп",
|
||||
"actionMode": "Переключить режим",
|
||||
"actionProxy": "Системный прокси",
|
||||
"actionTun": "TUN",
|
||||
"disclaimer": "Отказ от ответственности",
|
||||
"disclaimerDesc": "Это программное обеспечение используется только в некоммерческих целях, таких как учебные обмены и научные исследования. Запрещено использовать это программное обеспечение в коммерческих целях. Любая коммерческая деятельность, если таковая имеется, не имеет отношения к этому программному обеспечению.",
|
||||
"disclaimerDesc": "Это программное обеспечение предназначено только для некоммерческого использования: обучения, обмена опытом и научных исследований. Коммерческое использование строго запрещено; любая коммерческая деятельность не имеет отношения к этому программному обеспечению.",
|
||||
"agree": "Согласен",
|
||||
"hotkeyManagement": "Управление горячими клавишами",
|
||||
"hotkeyManagementDesc": "Использование клавиатуры для управления приложением",
|
||||
"pressKeyboard": "Пожалуйста, нажмите клавишу.",
|
||||
"inputCorrectHotkey": "Пожалуйста, введите правильную горячую клавишу",
|
||||
"hotkeyManagement": "Горячие клавиши",
|
||||
"hotkeyManagementDesc": "Управление приложением с клавиатуры",
|
||||
"pressKeyboard": "Нажмите клавишу",
|
||||
"inputCorrectHotkey": "Введите корректную горячую клавишу",
|
||||
"hotkeyConflict": "Конфликт горячих клавиш",
|
||||
"remove": "Удалить",
|
||||
"noHotKey": "Нет горячей клавиши",
|
||||
"remove": "Убрать",
|
||||
"noHotKey": "Горячих клавиш пока нет",
|
||||
"noNetwork": "Нет сети",
|
||||
"ipv6InboundDesc": "Разрешить входящий IPv6",
|
||||
"exportLogs": "Экспорт логов",
|
||||
"exportSuccess": "Экспорт успешен",
|
||||
"iconStyle": "Стиль иконки",
|
||||
"onlyIcon": "Только иконка",
|
||||
"exportSuccess": "Экспорт выполнен",
|
||||
"iconStyle": "Стиль значков",
|
||||
"onlyIcon": "Только значок",
|
||||
"stackMode": "Режим стека",
|
||||
"network": "Сеть",
|
||||
"networkDesc": "Изменение настроек, связанных с сетью",
|
||||
"bypassDomain": "Обход домена",
|
||||
"bypassDomainDesc": "Действует только при включенном системном прокси",
|
||||
"resetTip": "Убедитесь, что хотите сбросить",
|
||||
"icon": "Иконка",
|
||||
"networkDesc": "Настройки, связанные с сетью",
|
||||
"bypassDomain": "Исключённые домены",
|
||||
"bypassDomainDesc": "Действует только при включённом системном прокси",
|
||||
"resetTip": "Вы уверены, что хотите выполнить сброс?",
|
||||
"icon": "Значок",
|
||||
"noData": "Нет данных",
|
||||
"fontFamily": "Семейство шрифтов",
|
||||
"fontFamily": "Шрифт",
|
||||
"toggle": "Переключить",
|
||||
"system": "Система",
|
||||
"routeMode": "Режим маршрутизации",
|
||||
"routeMode_bypassPrivate": "Обход частных адресов маршрутизации",
|
||||
"routeMode_config": "Использовать конфигурацию",
|
||||
"routeAddress": "Адрес маршрутизации",
|
||||
"routeAddressDesc": "Настройка адреса прослушивания маршрутизации",
|
||||
"pleaseInputAdminPassword": "Пожалуйста, введите пароль администратора",
|
||||
"copyEnvVar": "Копирование переменных окружения",
|
||||
"memoryInfo": "Информация о памяти",
|
||||
"routeModeBypassPrivate": "Обходить частные адреса",
|
||||
"routeModeConfig": "Использовать конфигурацию",
|
||||
"routeAddress": "Адреса маршрутов",
|
||||
"routeAddressDesc": "Настроить прослушиваемые адреса маршрутов",
|
||||
"interfaceNameMode": "Исходящий интерфейс",
|
||||
"interfaceNameModeClear": "Очистить",
|
||||
"interfaceNameModeFollow": "Как в конфигурации",
|
||||
"interfaceNameModeCustom": "Вручную",
|
||||
"interfaceName": "Имя интерфейса",
|
||||
"interfaceNameDesc": "Сетевой интерфейс для исходящих соединений",
|
||||
"copyEnvVar": "Копировать переменные окружения",
|
||||
"memoryInfo": "Память",
|
||||
"cancel": "Отмена",
|
||||
"fileIsUpdate": "Файл был изменен. Хотите сохранить изменения?",
|
||||
"profileHasUpdate": "Профиль был изменен. Хотите отключить автообновление?",
|
||||
"hasCacheChange": "Хотите сохранить изменения в кэше?",
|
||||
"copySuccess": "Копирование успешно",
|
||||
"fileIsUpdate": "Файл изменён. Сохранить изменения?",
|
||||
"profileHasUpdate": "Профиль изменён. Отключить автообновление?",
|
||||
"hasCacheChange": "Кэшировать изменения?",
|
||||
"copySuccess": "Скопировано",
|
||||
"copyLink": "Копировать ссылку",
|
||||
"exportFile": "Экспорт файла",
|
||||
"cacheCorrupt": "Кэш поврежден. Хотите очистить его?",
|
||||
"detectionTip": "Опирается на сторонний API, только для справки",
|
||||
"listen": "Слушать",
|
||||
"cacheCorrupt": "Кэш повреждён. Очистить его?",
|
||||
"detectionTip": "Использует сторонний API; только для справки",
|
||||
"listen": "Прослушивание",
|
||||
"undo": "Отменить",
|
||||
"redo": "Повторить",
|
||||
"none": "Нет",
|
||||
"basicConfig": "Базовая конфигурация",
|
||||
"basicConfigDesc": "Глобальное изменение базовых настроек",
|
||||
"basicConfigDesc": "Глобальное изменение базовой конфигурации",
|
||||
"advancedConfig": "Расширенная конфигурация",
|
||||
"advancedConfigDesc": "Предоставляет разнообразные варианты конфигурации",
|
||||
"selectedCountTitle": "Выбрано {count} элементов",
|
||||
"advancedConfigDesc": "Разнообразные параметры конфигурации",
|
||||
"selectedCountTitle": "Выбрано: {count}",
|
||||
"addRule": "Добавить правило",
|
||||
"ruleName": "Название правила",
|
||||
"content": "Содержание",
|
||||
"content": "Содержимое",
|
||||
"subRule": "Подправило",
|
||||
"ruleTarget": "Цель правила",
|
||||
"sourceIp": "Исходный IP",
|
||||
"matchTargetTitle": "Цель MATCH",
|
||||
"matchTarget": "MATCH-TARGET",
|
||||
"followProfile": "Как в профиле",
|
||||
"matchTargetDesc": "Куда направляются правила с целью MATCH-TARGET. По умолчанию — цель последнего правила MATCH этого профиля.",
|
||||
"selectMatchTarget": "Выбрать MATCH-TARGET",
|
||||
"sourceIp": "IP источника",
|
||||
"noResolve": "Не разрешать IP",
|
||||
"saveChanges": "Сохранить изменения?",
|
||||
"findProcessModeDesc": "При включении возможны небольшие потери производительности",
|
||||
"tabAnimationDesc": "Действительно только в мобильном виде",
|
||||
"findProcessModeDesc": "При включении возможна небольшая потеря производительности",
|
||||
"tabAnimationDesc": "Действует только в мобильном виде",
|
||||
"colorSchemes": "Цветовые схемы",
|
||||
"palette": "Палитра",
|
||||
"tonalSpotScheme": "Тональный акцент",
|
||||
"fidelityScheme": "Точная передача",
|
||||
"monochromeScheme": "Монохром",
|
||||
"neutralScheme": "Нейтральные",
|
||||
"vibrantScheme": "Яркие",
|
||||
"expressiveScheme": "Экспрессивные",
|
||||
"contentScheme": "Контентная тема",
|
||||
"rainbowScheme": "Радужные",
|
||||
"neutralScheme": "Нейтральная",
|
||||
"vibrantScheme": "Яркая",
|
||||
"expressiveScheme": "Экспрессивная",
|
||||
"contentScheme": "Контентная",
|
||||
"rainbowScheme": "Радуга",
|
||||
"fruitSaladScheme": "Фруктовый микс",
|
||||
"developerMode": "Режим разработчика",
|
||||
"developerModeEnableTip": "Режим разработчика активирован.",
|
||||
"messageTest": "Тестирование сообщения",
|
||||
"developerModeEnableTip": "Режим разработчика включён.",
|
||||
"messageTest": "Тест сообщения",
|
||||
"messageTestTip": "Это сообщение.",
|
||||
"crashTest": "Тест на сбои",
|
||||
"crashTest": "Тест сбоя",
|
||||
"crashDetected": "Обнаружен сбой",
|
||||
"crashDetectedTip": "Во время предыдущего запуска произошёл сбой приложения. Чтобы предотвратить повторный сбой, текущий профиль был сброшен, а автоматическая настройка конфигурации пропущена.",
|
||||
"crashDetectedTip": "Приложение два раза подряд не смогло завершить запуск. Чтобы разорвать цикл, профиль {name} снят с выбора, а автоматическая настройка пропущена. Вы можете выбрать его снова в любой момент.",
|
||||
"launchInterrupted": "Запуск не завершён",
|
||||
"launchInterruptedTip": "В прошлый раз приложение неожиданно завершилось во время запуска. Автоматическая настройка для этого запуска пропущена; вы можете запустить её вручную.",
|
||||
"clearData": "Очистить данные",
|
||||
"textScale": "Масштабирование текста",
|
||||
"textScale": "Масштаб текста",
|
||||
"internet": "Интернет",
|
||||
"systemApp": "Системное приложение",
|
||||
"noNetworkApp": "Приложение без сети",
|
||||
"systemApp": "Системные приложения",
|
||||
"noNetworkApp": "Приложения без сети",
|
||||
"restoreStrategy": "Стратегия восстановления",
|
||||
"restoreStrategy_override": "Перезаписать",
|
||||
"restoreStrategy_compatible": "Совместимый",
|
||||
"logsTest": "Тест журналов",
|
||||
"emptyTip": "{label} не может быть пустым",
|
||||
"urlTip": "{label} должен быть URL",
|
||||
"numberTip": "{label} должно быть числом",
|
||||
"restoreStrategyOverride": "Перезапись",
|
||||
"restoreStrategyCompatible": "Совместимость",
|
||||
"logsTest": "Тест логов",
|
||||
"emptyTip": "Поле «{label}» не может быть пустым",
|
||||
"urlTip": "Значение «{label}» должно быть URL",
|
||||
"numberTip": "Значение «{label}» должно быть числом",
|
||||
"interval": "Интервал",
|
||||
"existsTip": "Текущий {label} уже существует",
|
||||
"deleteTip": "Вы уверены, что хотите удалить текущий {label}?",
|
||||
"deleteMultipTip": "Вы уверены, что хотите удалить выбранные {label}?",
|
||||
"nullTip": "{label} пока отсутствуют",
|
||||
"existsTip": "«{label}» уже существует",
|
||||
"maxLengthTip": "«{label}» — не более {max} символов",
|
||||
"multipleValuesTip": "Разделяйте несколько значений запятыми",
|
||||
"deleteTip": "Вы уверены, что хотите удалить «{label}»?",
|
||||
"deleteMultipTip": "Вы уверены, что хотите удалить выбранные элементы ({label})?",
|
||||
"nullTip": "Пока нет: {label}",
|
||||
"script": "Скрипт",
|
||||
"color": "Цвет",
|
||||
"rename": "Переименовать",
|
||||
"unnamed": "Без имени",
|
||||
"pleaseEnterScriptName": "Пожалуйста, введите название скрипта",
|
||||
"unnamed": "Без названия",
|
||||
"pleaseEnterScriptName": "Введите название скрипта",
|
||||
"mixedPort": "Смешанный порт",
|
||||
"socksPort": "Socks-порт",
|
||||
"redirPort": "Redir-порт",
|
||||
"tproxyPort": "Tproxy-порт",
|
||||
"portTip": "{label} должен быть числом от 1024 до 49151",
|
||||
"socksPort": "Порт SOCKS",
|
||||
"redirPort": "Порт Redir",
|
||||
"tproxyPort": "Порт TProxy",
|
||||
"portTip": "Значение «{label}» должно быть от 1024 до 49151",
|
||||
"portConflictTip": "Введите другой порт",
|
||||
"import": "Импорт",
|
||||
"importFile": "Импорт из файла",
|
||||
"importUrl": "Импорт по URL",
|
||||
"autoSetSystemDns": "Автоматическая настройка системного DNS",
|
||||
"details": "Детали {}",
|
||||
"autoSetSystemDns": "Автонастройка системного DNS",
|
||||
"details": "Сведения: {label}",
|
||||
"creationTime": "Время создания",
|
||||
"process": "процесс",
|
||||
"process": "Процесс",
|
||||
"host": "Хост",
|
||||
"destination": "Назначение",
|
||||
"destinationGeoIP": "Геолокация назначения",
|
||||
"destinationIPASN": "ASN назначения",
|
||||
"destinationGeoIP": "GeoIP назначения",
|
||||
"destinationIPASN": "ASN IP назначения",
|
||||
"specialProxy": "Специальный прокси",
|
||||
"specialRules": "Специальные правила",
|
||||
"remoteDestination": "Удалённое назначение",
|
||||
"networkType": "Тип сети",
|
||||
"proxyChains": "Цепочки прокси",
|
||||
"log": "Журнал",
|
||||
"proxyChains": "Цепочка прокси",
|
||||
"log": "Лог",
|
||||
"connection": "Соединение",
|
||||
"request": "Запрос",
|
||||
"connected": "Подключено",
|
||||
@@ -372,171 +399,176 @@
|
||||
"connecting": "Подключение...",
|
||||
"restartCoreTip": "Вы уверены, что хотите перезапустить ядро?",
|
||||
"forceRestartCoreTip": "Вы уверены, что хотите принудительно перезапустить ядро?",
|
||||
"dnsHijacking": "DNS-перехват",
|
||||
"coreStatus": "Основной статус",
|
||||
"dnsHijacking": "Перехват DNS",
|
||||
"coreStatus": "Статус ядра",
|
||||
"dataCollectionTip": "Уведомление о сборе данных",
|
||||
"dataCollectionContent": "Это приложение использует Firebase Crashlytics для сбора информации о сбоях nhằm улучшения стабильности приложения.\nСобираемые данные включают информацию об устройстве и подробности о сбоях, но не содержат персональных конфиденциальных данных.\nВы можете отключить эту функцию в настройках.",
|
||||
"crashlytics": "Анализ сбоев",
|
||||
"crashlyticsTip": "При включении автоматически загружает журналы сбоев без конфиденциальной информации, когда приложение выходит из строя",
|
||||
"appendSystemDns": "Добавить системный DNS",
|
||||
"appendSystemDnsTip": "Принудительно добавить системный DNS к конфигурации",
|
||||
"dataCollectionContent": "Это приложение использует Firebase Crashlytics для сбора информации о сбоях, чтобы повысить стабильность.\nСобираемые данные включают сведения об устройстве и подробности сбоя и не содержат личных конфиденциальных данных.\nЭту функцию можно отключить в настройках.",
|
||||
"crashlytics": "Аналитика сбоев",
|
||||
"crashlyticsTip": "При включении в случае сбоя приложения автоматически загружаются логи сбоя без конфиденциальной информации",
|
||||
"appendSystemDns": "Добавлять системный DNS",
|
||||
"appendSystemDnsTip": "Принудительно добавлять системный DNS в конфигурацию",
|
||||
"editRule": "Редактировать правило",
|
||||
"overrideMode": "Режим переопределения",
|
||||
"standardModeDesc": "Стандартный режим, переопределение базовой конфигурации, предоставление возможности простого добавления правил",
|
||||
"scriptModeDesc": "Режим скрипта, использование внешних расширяющих скриптов, предоставление возможности переопределения конфигурации одним кликом",
|
||||
"standardModeDesc": "Стандартный режим: переопределяет базовую конфигурацию и позволяет просто добавлять правила",
|
||||
"scriptModeDesc": "Режим скрипта: использует внешние скрипты-расширения для переопределения конфигурации в один клик",
|
||||
"addedRules": "Добавленные правила",
|
||||
"controlGlobalAddedRules": "Управление глобальными добавленными правилами",
|
||||
"overrideScript": "Скрипт переопределения",
|
||||
"goToConfigureScript": "Перейти к настройке скрипта",
|
||||
"editGlobalRules": "Редактировать глобальные правила",
|
||||
"externalFetch": "Внешнее получение",
|
||||
"confirmForceCrashCore": "Вы уверены, что хотите принудительно аварийно завершить работу ядра?",
|
||||
"confirmClearAllData": "Вы уверены, что хотите очистить все данные?",
|
||||
"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} дня назад}}",
|
||||
"hoursAgo": "{count, plural, one{{count} час назад} few{{count} часа назад} many{{count} часов назад} other{{count} часа назад}}",
|
||||
"minutesAgo": "{count, plural, one{{count} минута назад} few{{count} минуты назад} many{{count} минут назад} other{{count} минуты назад}}",
|
||||
"minutesAgo": "{count, plural, one{{count} минуту назад} few{{count} минуты назад} many{{count} минут назад} other{{count} минуты назад}}",
|
||||
"justNow": "Только что",
|
||||
"noLongerRemind": "Больше не напоминать",
|
||||
"accessControlSettings": "Настройки контроля доступа",
|
||||
"turnOn": "Включить",
|
||||
"turnOff": "Выключить",
|
||||
"vpnConfigChangeDetected": "Обнаружено изменение конфигурации VPN",
|
||||
"vpnConfigChangeDetected": "Обнаружено изменение настроек VPN",
|
||||
"restart": "Перезапустить",
|
||||
"speedStatistics": "Статистика скорости",
|
||||
"resetPageChangesTip": "На текущей странице есть изменения. Вы уверены, что хотите сбросить?",
|
||||
"resetPageChangesTip": "На этой странице есть изменения. Вы уверены, что хотите выполнить сброс?",
|
||||
"overwriteTypeCustom": "Пользовательский",
|
||||
"overwriteTypeCustomDesc": "Пользовательский режим, полная настройка групп прокси и правил",
|
||||
"overwriteTypeCustomDesc": "Пользовательский режим: полная настройка групп прокси и правил",
|
||||
"unknownNetworkError": "Неизвестная сетевая ошибка",
|
||||
"restoreException": "Ошибка восстановления",
|
||||
"networkException": "Ошибка сети, проверьте соединение и попробуйте еще раз",
|
||||
"invalidBackupFile": "Неверный файл резервной копии",
|
||||
"networkException": "Ошибка сети. Проверьте подключение и повторите попытку",
|
||||
"invalidBackupFile": "Недопустимый файл резервной копии",
|
||||
"pruneCache": "Очистить кэш",
|
||||
"backupAndRestore": "Резервное копирование и восстановление",
|
||||
"backupAndRestoreDesc": "Синхронизация данных через WebDAV или файлы",
|
||||
"restore": "Восстановить",
|
||||
"restoreSuccess": "Восстановление успешно",
|
||||
"restoreFromWebDAVDesc": "Восстановить данные через WebDAV",
|
||||
"restoreSuccess": "Восстановление выполнено",
|
||||
"restoreFromWebDAVDesc": "Восстановить данные из WebDAV",
|
||||
"restoreFromFileDesc": "Восстановить данные из файла",
|
||||
"restoreOnlyConfig": "Восстановить только файлы конфигурации",
|
||||
"restoreOnlyConfig": "Восстановить только профили",
|
||||
"restoreAllData": "Восстановить все данные",
|
||||
"addProfile": "Добавить профиль",
|
||||
"delayTest": "Тест задержки",
|
||||
"proxyGroupEmpty": "Группа прокси пуста",
|
||||
"proxyGroupNameEmpty": "Имя группы прокси не может быть пустым",
|
||||
"proxyGroupNameDuplicate": "Имя группы прокси дублируется",
|
||||
"confirmExitWindow": "Вы уверены, что хотите выйти из текущего окна?",
|
||||
"dataChangedSave": "Обнаружены изменения данных, хотите сохранить?",
|
||||
"proxyGroupNameEmpty": "Название группы прокси не может быть пустым",
|
||||
"proxyGroupNameDuplicate": "Название группы прокси уже используется",
|
||||
"confirmExitWindow": "Вы уверены, что хотите закрыть текущее окно?",
|
||||
"dataChangedSave": "Обнаружены изменения данных. Сохранить их?",
|
||||
"selectProxyProviders": "Выбрать провайдеров прокси",
|
||||
"proxyFilter": "Фильтр прокси",
|
||||
"proxyFilter": "Фильтр узлов",
|
||||
"optional": "Необязательно",
|
||||
"maxFailedTimes": "Макс. количество неудач",
|
||||
"maxFailedTimes": "Макс. число неудач",
|
||||
"testInterval": "Интервал тестирования",
|
||||
"excludeProxyFilter": "Исключить фильтр прокси",
|
||||
"excludeType": "Тип исключения",
|
||||
"excludeProxyFilter": "Фильтр исключения узлов",
|
||||
"excludeType": "Исключаемые типы",
|
||||
"expectedStatus": "Ожидаемый статус",
|
||||
"selectProxies": "Выбрать прокси",
|
||||
"inputProxyGroupName": "Введите имя группы прокси",
|
||||
"inputProxyGroupName": "Введите название группы прокси",
|
||||
"helperCorruptTip": "Служба Helper недоступна, поэтому TUN-режим включить нельзя. Переустановите FlClash.",
|
||||
"coreBlockedByPolicyTip": "Windows отказалась запускать FlClashCore.exe (ошибка {code}). Политики контроля приложений, такие как Smart App Control или AppLocker, блокируют неподписанные программы; разрешите FlClash в этой политике или отключите её и повторите попытку.",
|
||||
"coreBlockedBySmartAppControlTip": "Smart App Control в Windows заблокировал неподписанный FlClashCore.exe. Откройте Безопасность Windows → Управление приложениями и браузером → Параметры Smart App Control, выберите «Выкл.» и снова запустите FlClash. Повторно включить Smart App Control без переустановки Windows нельзя.",
|
||||
"hideFromList": "Скрыть из списка",
|
||||
"testWhenUsed": "Тестировать при использовании",
|
||||
"disableUDP": "Отключить UDP",
|
||||
"confirmDeleteProxyGroup": "Вы уверены, что хотите удалить текущую группу прокси?",
|
||||
"confirmDeleteProxyGroup": "Вы уверены, что хотите удалить эту группу прокси?",
|
||||
"ruleEmpty": "Правило пусто",
|
||||
"inputRuleContent": "Введите содержимое правила",
|
||||
"ruleSet": "Набор правил",
|
||||
"selectRuleSet": "Пожалуйста, выберите набор правил",
|
||||
"splitStrategy": "Стратегия разделения",
|
||||
"selectSplitStrategy": "Пожалуйста, выберите стратегию разделения",
|
||||
"selectSubRule": "Пожалуйста, выберите подправило",
|
||||
"selectRuleSet": "Выберите набор правил",
|
||||
"splitStrategy": "Стратегия распределения",
|
||||
"selectSplitStrategy": "Выберите стратегию распределения",
|
||||
"selectSubRule": "Выберите подправило",
|
||||
"noResolveHostname": "Не разрешать имя хоста",
|
||||
"matchSourceIp": "Сопоставить исходный IP",
|
||||
"matchSourceIp": "Сопоставлять IP источника",
|
||||
"basicInfo": "Основная информация",
|
||||
"additionalParameters": "Дополнительные параметры",
|
||||
"proxyType": "Тип прокси",
|
||||
"basicStrategy": "Базовая стратегия",
|
||||
"basicStrategy": "Базовые политики",
|
||||
"editProxy": "Редактировать прокси",
|
||||
"includeAllProxyProviders": "Включить всех провайдеров прокси",
|
||||
"includeAllProxyProvidersTip": "При включении это переопределит импортированных провайдеров прокси",
|
||||
"includeAllProxyProvidersTip": "При включении переопределяет подключённых провайдеров прокси",
|
||||
"addProxyProviders": "Добавить провайдеров прокси",
|
||||
"includeAllProxies": "Включить все прокси",
|
||||
"includeAllProxiesTip": "Импорт всех прокси, не содержащих группы прокси, дополнительные группы прокси можно добавить ниже",
|
||||
"includeAllProxiesTip": "Подключает все прокси вне групп; ниже можно добавить дополнительные группы прокси",
|
||||
"proxiesEmpty": "Список прокси пуст",
|
||||
"addProxies": "Добавить прокси",
|
||||
"addProxyGroup": "Добавить группу прокси",
|
||||
"editProxyGroup": "Редактировать группу прокси",
|
||||
"confirmOverwriteTip": "Существующие данные будут перезаписаны после подтверждения",
|
||||
"configDataDetected": "Данные обнаружены в конфигурации",
|
||||
"confirmOverwriteTip": "После подтверждения существующие данные будут перезаписаны",
|
||||
"configDataDetected": "В конфигурации обнаружены данные",
|
||||
"quickFill": "Быстрое заполнение",
|
||||
"iconUrl": "URL иконки",
|
||||
"iconRecords": "История иконок",
|
||||
"noRecords": "Нет записей",
|
||||
"custom": "Пользовательский",
|
||||
"iconUrl": "URL значка",
|
||||
"iconRecords": "История значков",
|
||||
"noRecords": "Записей пока нет",
|
||||
"custom": "Вручную",
|
||||
"ruleActionDomainDesc": "Сопоставить полный домен",
|
||||
"ruleActionDomainSuffixDesc": "Сопоставить суффикс домена",
|
||||
"ruleActionDomainKeywordDesc": "Сопоставить ключевое слово домена",
|
||||
"ruleActionDomainRegexDesc": "Сопоставление по маске, поддерживает только * и ?",
|
||||
"ruleActionGeositeDesc": "Сопоставить домены внутри Geosite",
|
||||
"ruleActionDomainKeywordDesc": "Сопоставить ключевое слово в домене",
|
||||
"ruleActionDomainRegexDesc": "Сопоставить по регулярному выражению домена",
|
||||
"ruleActionDomainWildcardDesc": "Сопоставление по маске; поддерживаются только * и ?",
|
||||
"ruleActionGeositeDesc": "Сопоставить домены из Geosite",
|
||||
"ruleActionIpCidrDesc": "Сопоставить диапазон IP-адресов",
|
||||
"ruleActionIpCidr6Desc": "Сопоставить диапазон IP-адресов (IP-CIDR6 — это просто псевдоним)",
|
||||
"ruleActionIpCidr6Desc": "Сопоставить диапазон IP-адресов; IP-CIDR6 — просто псевдоним",
|
||||
"ruleActionIpSuffixDesc": "Сопоставить диапазон суффиксов IP",
|
||||
"ruleActionIpAsnDesc": "Сопоставить ASN IP-адреса",
|
||||
"ruleActionIpAsnDesc": "Сопоставить ASN, которой принадлежит IP",
|
||||
"ruleActionGeoipDesc": "Сопоставить код страны IP-адреса",
|
||||
"ruleActionSrcGeoipDesc": "Сопоставить код страны исходного IP",
|
||||
"ruleActionSrcIpAsnDesc": "Сопоставить ASN исходного IP",
|
||||
"ruleActionSrcIpCidrDesc": "Сопоставить диапазон исходных IP-адресов",
|
||||
"ruleActionSrcIpSuffixDesc": "Сопоставить диапазон исходных суффиксов IP",
|
||||
"ruleActionDstPortDesc": "Сопоставить диапазон портов назначения запроса",
|
||||
"ruleActionSrcPortDesc": "Сопоставить диапазон портов источника запроса",
|
||||
"ruleActionSrcGeoipDesc": "Сопоставить код страны IP источника",
|
||||
"ruleActionSrcIpAsnDesc": "Сопоставить ASN IP источника",
|
||||
"ruleActionSrcIpCidrDesc": "Сопоставить диапазон IP-адресов источника",
|
||||
"ruleActionSrcIpSuffixDesc": "Сопоставить диапазон суффиксов IP источника",
|
||||
"ruleActionDstPortDesc": "Сопоставить диапазон портов назначения",
|
||||
"ruleActionSrcPortDesc": "Сопоставить диапазон портов источника",
|
||||
"ruleActionInPortDesc": "Сопоставить входящий порт",
|
||||
"ruleActionInTypeDesc": "Сопоставить входящий тип",
|
||||
"ruleActionInUserDesc": "Сопоставить входящее имя пользователя, поддерживает несколько имен через /",
|
||||
"ruleActionInNameDesc": "Сопоставить входящее имя",
|
||||
"ruleActionInTypeDesc": "Сопоставить тип входящего подключения",
|
||||
"ruleActionInUserDesc": "Сопоставить имя пользователя входящего подключения; несколько имён разделяются /",
|
||||
"ruleActionInNameDesc": "Сопоставить имя входящего подключения",
|
||||
"ruleActionRematchNameDesc": "Сопоставить имя повторного сопоставления; несколько имён разделяются /",
|
||||
"ruleActionProcessPathDesc": "Сопоставить по полному пути процесса",
|
||||
"ruleActionProcessPathRegexDesc": "Сопоставить по регулярному выражению пути процесса",
|
||||
"ruleActionProcessNameDesc": "Сопоставить по имени процесса, на Android соответствует имени пакета",
|
||||
"ruleActionProcessNameRegexDesc": "Сопоставить по регулярному выражению имени процесса, на Android соответствует имени пакета",
|
||||
"ruleActionProcessPathWildcardDesc": "Сопоставить по маске пути процесса; поддерживаются только * и ?",
|
||||
"ruleActionProcessNameDesc": "Сопоставить по имени процесса; на Android соответствует имени пакета",
|
||||
"ruleActionProcessNameRegexDesc": "Сопоставить по регулярному выражению имени процесса; на Android соответствует имени пакета",
|
||||
"ruleActionProcessNameWildcardDesc": "Сопоставить по маске имени процесса; поддерживаются только * и ?",
|
||||
"ruleActionUidDesc": "Сопоставить Linux USER ID",
|
||||
"ruleActionNetworkDesc": "Сопоставить TCP или UDP",
|
||||
"ruleActionDscpDesc": "Сопоставить метку DSCP (только для tproxy udp inbound)",
|
||||
"ruleActionRuleSetDesc": "Ссылка на набор правил, требуется настройка rule-providers",
|
||||
"ruleActionDscpDesc": "Сопоставить метку DSCP (только для входящих tproxy UDP)",
|
||||
"ruleActionRuleSetDesc": "Ссылка на набор правил; требуется настроить rule-providers",
|
||||
"ruleActionAndDesc": "Логическое правило AND",
|
||||
"ruleActionOrDesc": "Логическое правило OR",
|
||||
"ruleActionNotDesc": "Логическое правило NOT",
|
||||
"ruleActionSubRuleDesc": "Сопоставить с подправилом, обратите внимание на использование скобок",
|
||||
"ruleActionMatchDesc": "Сопоставить все запросы, условия не требуются",
|
||||
"ruleActionSubRuleDesc": "Переход к подправилу; обратите внимание на скобки",
|
||||
"ruleActionMatchDesc": "Сопоставляет все запросы, условия не нужны",
|
||||
"subRuleEmpty": "Подправило пусто",
|
||||
"proxyProvidersNotEmpty": "Провайдеры прокси не могут быть пустыми",
|
||||
"contentNotEmpty": "Содержимое не может быть пустым",
|
||||
"subRuleNotEmpty": "Подправило не может быть пустым",
|
||||
"splitStrategyNotEmpty": "Стратегия разделения не может быть пустой",
|
||||
"proxyProvidersEmpty": "Провайдеры прокси пусты",
|
||||
"timeout": "Таймаут",
|
||||
"invalidSubRule": "{subRule} является недопустимым подправилом",
|
||||
"invalidPolicy": "{target} является недопустимой политикой",
|
||||
"invalidProxyProvider": "{providerName} является недопустимым провайдером прокси",
|
||||
"invalidProxy": "{proxyName} является недопустимым прокси",
|
||||
"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",
|
||||
"locationPermissionDeniedMessage": "Разрешение на геолокацию отклонено, поэтому невозможно получить имя текущей Wi-Fi сети. Включите разрешение на геолокацию вручную в системных настройках.",
|
||||
"splitStrategyNotEmpty": "Стратегия распределения не может быть пустой",
|
||||
"proxyProvidersEmpty": "Список провайдеров прокси пуст",
|
||||
"timeout": "Тайм-аут",
|
||||
"invalidSubRule": "{subRule} — недопустимый SUB_RULE",
|
||||
"invalidPolicy": "{target} — недопустимая политика",
|
||||
"invalidProxyProvider": "{providerName} — недопустимый провайдер прокси",
|
||||
"invalidProxy": "{proxyName} — недопустимый прокси",
|
||||
"proxyGroupDetectedAbnormal": "Обнаружены отклонения в текущей группе прокси",
|
||||
"proxyProviderDetectedAbnormal": "Обнаружены отклонения в выбранных провайдерах прокси",
|
||||
"proxyDetectedAbnormal": "Обнаружены отклонения в выбранных прокси",
|
||||
"createProfile": "Создать профиль",
|
||||
"locationPermissionRequired": "Требуется разрешение на геолокацию",
|
||||
"locationPermissionGuide": "1. Откройте Системные настройки > Конфиденциальность и безопасность\n2. Выберите Службы геолокации\n3. Найдите и отметьте {appName} в списке\n\nПосле настройки вернитесь в приложение и продолжайте работу. Спасибо за сотрудничество.",
|
||||
"prerequisites": "Предварительные условия",
|
||||
"ignoreBatteryOptimization": "Игнорировать оптимизацию батареи",
|
||||
"batteryOptimizationDesc": "Чтобы приложение работало в фоне, отключите для него оптимизацию батареи. Нажмите, чтобы перейти к настройкам.",
|
||||
"batteryOptimizationStatusTip": "Из-за системных ограничений во время работы невозможно корректно получить статус оптимизации батареи",
|
||||
"locationPermission": "Разрешение на геолокацию",
|
||||
"locationPermissionDesc": "По требованию системы для получения имени сети Wi-Fi необходимо разрешение на геолокацию. На Android выберите «Разрешить всегда», иначе имя сети Wi-Fi нельзя получить, пока приложение в фоне.",
|
||||
"excludeSsids": "Исключённые SSID",
|
||||
"excludeSsidsDesc": "При подключении к Wi-Fi с исключённым SSID состояние работы приложения переключается автоматически",
|
||||
"ssidsEmpty": "Список SSID пуст",
|
||||
"onDemand": "По условию",
|
||||
"onDemandDesc": "Настройте состояние работы приложения для определённых сценариев",
|
||||
"locationPermissionDeniedMessage": "Разрешение на геолокацию отклонено, поэтому невозможно получить имя текущей сети Wi-Fi. Включите разрешение на геолокацию вручную в системных настройках.",
|
||||
"addSsid": "Добавить SSID",
|
||||
"editSsid": "Изменить SSID",
|
||||
"authorized": "Разрешено",
|
||||
@@ -547,11 +579,43 @@
|
||||
"geoAutoUpdateInterval": "Интервал автообновления",
|
||||
"geoAutoUpdateIntervalTip": "Интервал автообновления должен быть больше 0",
|
||||
"hours": "часов",
|
||||
"hoursCount": "{count} часов",
|
||||
"hoursCount": "{count, plural, one{{count} час} few{{count} часа} many{{count} часов} other{{count} часа}}",
|
||||
"geoResources": "Ресурсы Geo",
|
||||
"geoUpdating": "Обновление {name}...",
|
||||
"geoSkipped": "Для {name} уже установлена последняя версия",
|
||||
"geoUpdated": "{name} обновлено",
|
||||
"secondsCount": "{count} секунд",
|
||||
"entriesCount": "{count} записей"
|
||||
"geoSkipped": "{name}: уже последняя версия",
|
||||
"geoUpdated": "{name}: обновлено",
|
||||
"secondsCount": "{count, plural, one{{count} секунда} few{{count} секунды} many{{count} секунд} other{{count} секунды}}",
|
||||
"entriesCount": "{count, plural, one{{count} запись} few{{count} записи} many{{count} записей} other{{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": "Открепить окно",
|
||||
"exitFullScreen": "Выйти из полноэкранного режима",
|
||||
"toggleLabel": "Переключить подписи",
|
||||
"torch": "Фонарик",
|
||||
"pickFromAlbum": "Выбрать из галереи",
|
||||
"blockConnection": "Заблокировать соединение",
|
||||
"closeConnections": "Закрыть соединения",
|
||||
"scrollToSelected": "Прокрутить к выбранному",
|
||||
"showMore": "Развернуть",
|
||||
"showLess": "Свернуть",
|
||||
"previousMatch": "Предыдущее совпадение",
|
||||
"nextMatch": "Следующее совпадение",
|
||||
"clearSearch": "Очистить поиск",
|
||||
"addWidget": "Добавить виджет",
|
||||
"showPassword": "Показать пароль",
|
||||
"hidePassword": "Скрыть пароль",
|
||||
"authorize": "Разрешить",
|
||||
"installedAppsPermissionRequired": "Требуется разрешение на список приложений",
|
||||
"installedAppsPermissionDesc": "Эта система не выдаёт список установленных приложений без разрешения. Предоставьте его, чтобы настроить прокси для отдельных приложений.",
|
||||
"installedAppsPermissionDeniedMessage": "Разрешение на список приложений отклонено, поэтому установленные приложения недоступны. Предоставьте его вручную в системных настройках."
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"rule": "规则",
|
||||
"rules": "规则",
|
||||
"global": "全局",
|
||||
"direct": "直连",
|
||||
"dashboard": "仪表盘",
|
||||
@@ -17,6 +18,9 @@
|
||||
"networkDetection": "网络检测",
|
||||
"upload": "上传",
|
||||
"download": "下载",
|
||||
"usedTraffic": "已用流量",
|
||||
"totalTraffic": "总流量",
|
||||
"expireTime": "到期时间",
|
||||
"nullProfileDesc": "没有配置文件,请先添加配置文件",
|
||||
"settings": "设置",
|
||||
"language": "语言",
|
||||
@@ -27,7 +31,7 @@
|
||||
"en": "英语",
|
||||
"ja": "日语",
|
||||
"ru": "俄语",
|
||||
"zh_CN": "中文简体",
|
||||
"zhCN": "中文简体",
|
||||
"theme": "主题",
|
||||
"themeDesc": "设置深色模式,调整色彩",
|
||||
"override": "覆写",
|
||||
@@ -47,6 +51,8 @@
|
||||
"logcatDesc": "禁用将会隐藏日志入口",
|
||||
"autoCheckUpdate": "自动检查更新",
|
||||
"autoCheckUpdateDesc": "应用启动时自动检查更新",
|
||||
"checkCertificate": "校验 TLS 证书",
|
||||
"checkCertificateDesc": "拒绝不受信任的证书。关闭后订阅和备份将暴露于中间人攻击",
|
||||
"accessControl": "访问控制",
|
||||
"accessControlDesc": "配置应用访问代理",
|
||||
"application": "应用程序",
|
||||
@@ -80,7 +86,7 @@
|
||||
"dark": "深色",
|
||||
"importFromURL": "从URL导入",
|
||||
"submit": "提交",
|
||||
"doYouWantToPass": "是否要通过",
|
||||
"createProfileFromUrlTip": "是否要通过 {url} 创建配置?",
|
||||
"create": "创建",
|
||||
"pleaseUploadValidQrcode": "请上传有效的二维码",
|
||||
"blacklistMode": "黑名单模式",
|
||||
@@ -90,8 +96,8 @@
|
||||
"appAccessControl": "应用访问控制",
|
||||
"accessControlAllowDesc": "只允许选中应用进入VPN",
|
||||
"accessControlNotAllowDesc": "选中应用将会被排除在VPN之外",
|
||||
"accessControlDisabledDesc": "应用访问控制已关闭",
|
||||
"selected": "已选择",
|
||||
"proxyPort": "代理端口",
|
||||
"port": "端口",
|
||||
"logLevel": "日志等级",
|
||||
"show": "显示",
|
||||
@@ -105,6 +111,8 @@
|
||||
"stopVpn": "正在停止VPN...",
|
||||
"compatible": "兼容模式",
|
||||
"notSelectedTip": "当前代理组无法选中",
|
||||
"changeProxyFailedTip": "切换代理失败,已恢复上一次的选择",
|
||||
"databaseWriteFailedTip": "保存更改失败,已回滚",
|
||||
"tip": "提示",
|
||||
"account": "账号",
|
||||
"backup": "备份",
|
||||
@@ -118,6 +126,9 @@
|
||||
"addressHelp": "WebDAV服务器地址",
|
||||
"addressTip": "请输入有效的WebDAV地址",
|
||||
"password": "密码",
|
||||
"authentication": "认证",
|
||||
"authenticationDesc": "为本地代理端口启用认证,防止本机其他应用擅自使用",
|
||||
"authenticationSystemProxyDesc": "认证启用时不生效",
|
||||
"checkUpdate": "检查更新",
|
||||
"discoverNewVersion": "发现新版本",
|
||||
"checkUpdateError": "当前应用已经是最新版了",
|
||||
@@ -146,6 +157,7 @@
|
||||
"init": "初始化",
|
||||
"infiniteTime": "长期有效",
|
||||
"connections": "连接",
|
||||
"inbound": "入站",
|
||||
"connectionsDesc": "查看当前连接数据",
|
||||
"intranetIP": "内网 IP",
|
||||
"view": "查看",
|
||||
@@ -174,6 +186,8 @@
|
||||
"autoCloseConnectionsDesc": "切换节点后自动关闭连接",
|
||||
"onlyStatisticsProxy": "仅统计代理",
|
||||
"onlyStatisticsProxyDesc": "开启后,将只统计代理流量",
|
||||
"showNotificationStopAction": "通知栏显示停止按钮",
|
||||
"showNotificationStopActionDesc": "在常驻通知上显示停止按钮。若系统因此总是展开通知,可关闭",
|
||||
"pureBlackMode": "纯黑模式",
|
||||
"keepAliveIntervalDesc": "TCP保持活动间隔",
|
||||
"entries": "个条目",
|
||||
@@ -204,9 +218,9 @@
|
||||
"options": "选项",
|
||||
"loopback": "回环解锁工具",
|
||||
"loopbackDesc": "用于UWP回环解锁",
|
||||
"providers": "提供者",
|
||||
"providers": "外部资源",
|
||||
"proxyProviders": "代理集",
|
||||
"ruleProviders": "规则集",
|
||||
"subscriptionInfo": "订阅信息",
|
||||
"overrideDns": "覆写DNS",
|
||||
"overrideDnsDesc": "开启后将覆盖配置中的DNS选项",
|
||||
"status": "状态",
|
||||
@@ -234,11 +248,11 @@
|
||||
"ipcidr": "IP/掩码",
|
||||
"domain": "域名",
|
||||
"reset": "重置",
|
||||
"action_view": "显示/隐藏",
|
||||
"action_start": "启动/停止",
|
||||
"action_mode": "切换模式",
|
||||
"action_proxy": "系统代理",
|
||||
"action_tun": "虚拟网卡",
|
||||
"actionView": "显示/隐藏",
|
||||
"actionStart": "启动/停止",
|
||||
"actionMode": "切换模式",
|
||||
"actionProxy": "系统代理",
|
||||
"actionTun": "虚拟网卡",
|
||||
"disclaimer": "免责声明",
|
||||
"disclaimerDesc": "本软件仅供学习交流、科研等非商业性质的用途,严禁将本软件用于商业目的。如有任何商业行为,均与本软件无关。",
|
||||
"agree": "同意",
|
||||
@@ -267,11 +281,16 @@
|
||||
"toggle": "切换",
|
||||
"system": "系统",
|
||||
"routeMode": "路由模式",
|
||||
"routeMode_bypassPrivate": "绕过私有路由地址",
|
||||
"routeMode_config": "使用配置",
|
||||
"routeModeBypassPrivate": "绕过私有路由地址",
|
||||
"routeModeConfig": "使用配置",
|
||||
"routeAddress": "路由地址",
|
||||
"routeAddressDesc": "配置监听路由地址",
|
||||
"pleaseInputAdminPassword": "请输入管理员密码",
|
||||
"interfaceNameMode": "出站网卡",
|
||||
"interfaceNameModeClear": "清空",
|
||||
"interfaceNameModeFollow": "跟随配置",
|
||||
"interfaceNameModeCustom": "自定义",
|
||||
"interfaceName": "网卡名称",
|
||||
"interfaceNameDesc": "出站连接使用的网卡名称",
|
||||
"copyEnvVar": "复制环境变量",
|
||||
"memoryInfo": "内存信息",
|
||||
"cancel": "取消",
|
||||
@@ -287,8 +306,8 @@
|
||||
"undo": "撤销",
|
||||
"redo": "重做",
|
||||
"none": "无",
|
||||
"basicConfig": "基本配置",
|
||||
"basicConfigDesc": "全局修改基本配置",
|
||||
"basicConfig": "基础配置",
|
||||
"basicConfigDesc": "全局修改基础配置",
|
||||
"advancedConfig": "进阶配置",
|
||||
"advancedConfigDesc": "提供多样化配置",
|
||||
"selectedCountTitle": "已选择 {count} 项",
|
||||
@@ -297,6 +316,11 @@
|
||||
"content": "内容",
|
||||
"subRule": "子规则",
|
||||
"ruleTarget": "规则目标",
|
||||
"matchTargetTitle": "匹配目标",
|
||||
"matchTarget": "MATCH-TARGET",
|
||||
"followProfile": "跟随配置",
|
||||
"matchTargetDesc": "目标为 MATCH-TARGET 的规则路由到这里,默认取本配置末尾 MATCH 规则的目标。",
|
||||
"selectMatchTarget": "选择 MATCH-TARGET",
|
||||
"sourceIp": "源IP",
|
||||
"noResolve": "不解析IP",
|
||||
"saveChanges": "是否保存更改?",
|
||||
@@ -319,27 +343,30 @@
|
||||
"messageTestTip": "这是一条消息。",
|
||||
"crashTest": "崩溃测试",
|
||||
"crashDetected": "检测到崩溃",
|
||||
"crashDetectedTip": "检测到应用上次运行发生崩溃。为避免重复崩溃,已清除当前配置选择,并跳过本次自动配置。",
|
||||
"crashDetectedTip": "应用连续两次未能完成启动。为打断崩溃循环,已取消选中配置 {name},并跳过本次自动配置,你可以随时重新选中它。",
|
||||
"launchInterrupted": "启动未完成",
|
||||
"launchInterruptedTip": "应用上次在启动过程中意外退出。已跳过本次自动配置,你可以手动启动重试。",
|
||||
"clearData": "清除数据",
|
||||
"textScale": "文本缩放",
|
||||
"internet": "互联网",
|
||||
"systemApp": "系统应用",
|
||||
"noNetworkApp": "无网络应用",
|
||||
"restoreStrategy": "恢复策略",
|
||||
"restoreStrategy_override": "覆盖",
|
||||
"restoreStrategy_compatible": "兼容",
|
||||
"restoreStrategyOverride": "覆盖",
|
||||
"restoreStrategyCompatible": "兼容",
|
||||
"logsTest": "日志测试",
|
||||
"emptyTip": "{label}不能为空",
|
||||
"urlTip": "{label}必须为URL",
|
||||
"numberTip": "{label}必须为数字",
|
||||
"interval": "间隔",
|
||||
"existsTip": "{label}当前已存在",
|
||||
"maxLengthTip": "{label}最多{max}个字符",
|
||||
"multipleValuesTip": "多个值使用逗号分隔",
|
||||
"deleteTip": "确定删除当前{label}吗?",
|
||||
"deleteMultipTip": "确定删除选中的{label}吗?",
|
||||
"nullTip": "暂无{label}",
|
||||
"script": "脚本",
|
||||
"color": "颜色",
|
||||
"rename": "重命名",
|
||||
"unnamed": "未命名",
|
||||
"pleaseEnterScriptName": "请输入脚本名称",
|
||||
"mixedPort": "混合端口",
|
||||
@@ -382,7 +409,7 @@
|
||||
"appendSystemDnsTip": "强制为配置附加系统DNS",
|
||||
"editRule": "编辑规则",
|
||||
"overrideMode": "覆写模式",
|
||||
"standardModeDesc": "标准模式,覆写基本配置,提供简单追加规则能力",
|
||||
"standardModeDesc": "标准模式,覆写基础配置,提供简单追加规则能力",
|
||||
"scriptModeDesc": "脚本模式,使用外部扩展脚本,提供一键覆写配置的能力",
|
||||
"addedRules": "附加规则",
|
||||
"controlGlobalAddedRules": "控制全局附加规则",
|
||||
@@ -393,7 +420,6 @@
|
||||
"confirmForceCrashCore": "确定要强制崩溃核心?",
|
||||
"confirmClearAllData": "确定要清除所有数据?",
|
||||
"loading": "加载中...",
|
||||
"loadTest": "加载测试",
|
||||
"yearsAgo": "{count} 年前",
|
||||
"monthsAgo": "{count} 个月前",
|
||||
"daysAgo": "{count} 天前",
|
||||
@@ -441,6 +467,8 @@
|
||||
"selectProxies": "选择代理",
|
||||
"inputProxyGroupName": "输入策略组名称",
|
||||
"helperCorruptTip": "Helper 服务不可用,无法启用 TUN 模式,请重新安装 FlClash。",
|
||||
"coreBlockedByPolicyTip": "Windows 拒绝运行 FlClashCore.exe(错误 {code})。智能应用控制、AppLocker 等应用控制策略会拦截未签名程序,请在该策略中放行 FlClash 或关闭策略后重试。",
|
||||
"coreBlockedBySmartAppControlTip": "Windows 智能应用控制拦截了未签名的 FlClashCore.exe。请打开 Windows 安全中心 → 应用和浏览器控制 → 智能应用控制设置,选择「关闭」后重新启动 FlClash。智能应用控制关闭后无法再开启,除非重装 Windows。",
|
||||
"hideFromList": "从列表中隐藏",
|
||||
"testWhenUsed": "使用时测试",
|
||||
"disableUDP": "禁用UDP",
|
||||
@@ -478,7 +506,8 @@
|
||||
"ruleActionDomainDesc": "匹配完整域名",
|
||||
"ruleActionDomainSuffixDesc": "匹配域名后缀",
|
||||
"ruleActionDomainKeywordDesc": "匹配域名关键字",
|
||||
"ruleActionDomainRegexDesc": "通配符匹配,仅支持*和?通配符",
|
||||
"ruleActionDomainRegexDesc": "使用域名正则表达式匹配",
|
||||
"ruleActionDomainWildcardDesc": "通配符匹配,仅支持*和?通配符",
|
||||
"ruleActionGeositeDesc": "匹配 Geosite 内的域名",
|
||||
"ruleActionIpCidrDesc": "匹配 IP 地址范围",
|
||||
"ruleActionIpCidr6Desc": "匹配 IP 地址范围, IP-CIDR6 只是一个别名",
|
||||
@@ -495,10 +524,13 @@
|
||||
"ruleActionInTypeDesc": "匹配入站类型",
|
||||
"ruleActionInUserDesc": "匹配入站用户名,支持使用 / 分隔多个用户名",
|
||||
"ruleActionInNameDesc": "匹配入站名称",
|
||||
"ruleActionRematchNameDesc": "匹配重匹配名称,多个名称用/分隔",
|
||||
"ruleActionProcessPathDesc": "使用完整进程路径匹配",
|
||||
"ruleActionProcessPathRegexDesc": "使用进程路径正则表达式匹配",
|
||||
"ruleActionProcessPathWildcardDesc": "使用进程路径通配符匹配,仅支持*和?通配符",
|
||||
"ruleActionProcessNameDesc": "使用进程匹配,在Android平台可以匹配包名",
|
||||
"ruleActionProcessNameRegexDesc": "使用进程名称正则表达式匹配,在Android平台可以匹配包名",
|
||||
"ruleActionProcessNameWildcardDesc": "使用进程名称通配符匹配,仅支持*和?通配符",
|
||||
"ruleActionUidDesc": "匹配 Linux USER ID",
|
||||
"ruleActionNetworkDesc": "匹配TCP或者UDP",
|
||||
"ruleActionDscpDesc": "匹配DSCP标记 (仅限 tproxy udp 入站)",
|
||||
@@ -528,9 +560,9 @@
|
||||
"prerequisites": "前置条件",
|
||||
"ignoreBatteryOptimization": "忽略电池优化",
|
||||
"batteryOptimizationDesc": "为保证后台运行,请关闭本应用的电池优化。点击前往设置。",
|
||||
"batteryOptimizationStatusTip": "受系统影响,不代表一定准确",
|
||||
"batteryOptimizationStatusTip": "由于系统限制,运行状态下无法正确获取电池优化状态",
|
||||
"locationPermission": "位置权限",
|
||||
"locationPermissionDesc": "根据系统要求,获取Wi-Fi名称需要您授予位置权限。",
|
||||
"locationPermissionDesc": "根据系统要求,获取Wi-Fi名称需要您授予位置权限。Android 上请选择“始终允许”,否则应用在后台时无法获取 Wi-Fi 名称。",
|
||||
"excludeSsids": "排除SSIDs",
|
||||
"excludeSsidsDesc": "连接到被排除SSID的WIFI时,将会自动切换应用运行状态",
|
||||
"ssidsEmpty": "SSIDs为空",
|
||||
@@ -549,9 +581,41 @@
|
||||
"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": "取消置顶",
|
||||
"exitFullScreen": "退出全屏",
|
||||
"toggleLabel": "切换标签",
|
||||
"torch": "手电筒",
|
||||
"pickFromAlbum": "从相册选择",
|
||||
"blockConnection": "阻止连接",
|
||||
"closeConnections": "关闭连接",
|
||||
"scrollToSelected": "滚动到已选",
|
||||
"showMore": "展开",
|
||||
"showLess": "收起",
|
||||
"previousMatch": "上一个匹配",
|
||||
"nextMatch": "下一个匹配",
|
||||
"clearSearch": "清除搜索",
|
||||
"addWidget": "添加组件",
|
||||
"showPassword": "显示密码",
|
||||
"hidePassword": "隐藏密码",
|
||||
"authorize": "授权",
|
||||
"installedAppsPermissionRequired": "需要读取应用列表权限",
|
||||
"installedAppsPermissionDesc": "当前系统在授权前不会提供已安装的应用列表,授权后即可配置分应用代理。",
|
||||
"installedAppsPermissionDeniedMessage": "读取应用列表权限已被拒绝,无法获取已安装的应用。请前往系统设置手动开启。"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="69" y="69" width="70" height="70" rx="22" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<rect x="161" y="69" width="70" height="70" rx="35" fill="#6750A4"/>
|
||||
<rect x="69" y="161" width="70" height="70" rx="35" fill="#6750A4" opacity="0.58"/>
|
||||
<path d="M174 161H218C225.18 161 231 166.82 231 174V218C231 225.18 225.18 231 218 231H174C166.82 231 161 225.18 161 218V174C161 166.82 166.82 161 174 161Z" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<path d="M90 104H118" stroke="#6750A4" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M104 90V118" stroke="#6750A4" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M181 196H211" stroke="#6750A4" stroke-width="8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 827 B |
@@ -1,23 +1,5 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="75" y="50" width="150" height="180" rx="24" fill="#FDF7FF" stroke="#E8DEF8" stroke-width="2"/>
|
||||
|
||||
<rect x="95" y="90" width="36" height="12" rx="4" fill="#E8DEF8"/>
|
||||
<rect x="140" y="90" width="65" height="12" rx="6" fill="#E8DEF8"/>
|
||||
|
||||
<path d="M95 118H205" stroke="#E8DEF8" stroke-width="2" stroke-dasharray="4 4"/>
|
||||
|
||||
<rect x="95" y="138" width="40" height="12" rx="6" fill="#E8DEF8" opacity="0.7"/>
|
||||
<rect x="145" y="138" width="50" height="12" rx="6" fill="#E8DEF8" opacity="0.5"/>
|
||||
|
||||
<rect x="95" y="162" width="55" height="12" rx="6" fill="#E8DEF8" opacity="0.7"/>
|
||||
<rect x="160" y="162" width="30" height="12" rx="6" fill="#E8DEF8" opacity="0.5"/>
|
||||
|
||||
<g transform="translate(210, 210)">
|
||||
<circle cx="0" cy="0" r="38" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
|
||||
|
||||
<path d="M-10 16V-16M-10 -16L-18 -8M-10 -16L-2 -8" stroke="#FDF7FF" stroke-width="5" stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
<path d="M10 -16V16M10 16L2 8M10 16L18 8" stroke="#FDF7FF" stroke-width="5" stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
<path d="M122 150H178" stroke="#6750A4" stroke-width="14" stroke-linecap="round"/>
|
||||
<circle cx="92" cy="150" r="34" fill="#FDF7FF" stroke="#6750A4" stroke-width="9"/>
|
||||
<circle cx="208" cy="150" r="34" fill="#6750A4"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 329 B |
@@ -1,9 +1,5 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="75" y="50" width="150" height="180" rx="24" fill="#FDF7FF" stroke="#E8DEF8" stroke-width="2"/>
|
||||
<rect x="100" y="90" width="100" height="12" rx="6" fill="#E8DEF8"/>
|
||||
<rect x="100" y="115" width="70" height="12" rx="6" fill="#E8DEF8"/>
|
||||
<rect x="100" y="140" width="80" height="12" rx="6" fill="#E8DEF8"/>
|
||||
<rect x="155" y="170" width="80" height="60" rx="12" fill="#6750A4"/>
|
||||
<rect x="150" y="165" width="90" height="18" rx="6" fill="#6750A4" stroke="#FDF7FF" stroke-width="2"/>
|
||||
<rect x="185" y="200" width="20" height="6" rx="3" fill="#FDF7FF" opacity="0.8"/>
|
||||
</svg>
|
||||
<path d="M82 108C82 90.33 112.45 76 150 76C187.55 76 218 90.33 218 108V197C218 214.67 187.55 229 150 229C112.45 229 82 214.67 82 197V108Z" fill="#6750A4"/>
|
||||
<ellipse cx="150" cy="108" rx="52" ry="17" fill="#FDF7FF"/>
|
||||
<path d="M105 161C128 170 172 170 195 161" stroke="#FDF7FF" stroke-width="9" stroke-linecap="round" opacity="0.72"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 700 B After Width: | Height: | Size: 446 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="150" cy="150" r="78" fill="#FDF7FF" stroke="#6750A4" stroke-width="12"/>
|
||||
<path d="M150 105V154L183 177" stroke="#6750A4" stroke-width="13" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 311 B |
@@ -1,25 +1,10 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M90 200C90 180 100 165 150 165C200 165 210 180 210 200V220C210 231.046 201.046 240 190 240H110C98.9543 240 90 231.046 90 220V200Z"
|
||||
fill="#E8DEF8"/>
|
||||
|
||||
<rect x="75" y="85" width="150" height="100" rx="30" fill="#6750A4"/>
|
||||
|
||||
<rect x="85" y="95" width="130" height="80" rx="22" fill="#6750A4" stroke="#7D66B5" stroke-width="2"/>
|
||||
|
||||
<path d="M110 135 C110 142 118 148 128 148 C138 148 146 142 146 135" stroke="#FDF7FF" stroke-width="4"
|
||||
stroke-linecap="round"/>
|
||||
<path d="M154 135 C154 142 162 148 172 148 C182 148 190 142 190 135" stroke="#FDF7FF" stroke-width="4"
|
||||
stroke-linecap="round"/>
|
||||
<circle cx="150" cy="160" r="4" fill="#E8DEF8" opacity="0.5"/>
|
||||
|
||||
<path d="M150 85 V 65" stroke="#6750A4" stroke-width="4" stroke-linecap="round"/>
|
||||
<circle cx="150" cy="60" r="8" fill="#6750A4"/>
|
||||
<circle cx="150" cy="60" r="3" fill="#FDF7FF"/>
|
||||
|
||||
<path d="M220 70 L235 70 L220 85 H235" stroke="#6750A4" stroke-width="3" stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
<path d="M245 40 L255 40 L245 50 H255" stroke="#E8DEF8" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<path d="M90 185 H210" stroke="#000" stroke-width="4" stroke-opacity="0.1" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<rect x="57" y="72" width="186" height="156" rx="28" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<path d="M57 100C57 84.54 69.54 72 85 72H215C230.46 72 243 84.54 243 100V112H57V100Z" fill="#E8DEF8"/>
|
||||
<circle cx="84" cy="92" r="6" fill="#6750A4"/>
|
||||
<circle cx="104" cy="92" r="6" fill="#6750A4" opacity="0.55"/>
|
||||
<path d="M88 150L108 166L88 182" stroke="#6750A4" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M126 183H169" stroke="#6750A4" stroke-width="10" stroke-linecap="round"/>
|
||||
<circle cx="215" cy="204" r="34" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
|
||||
<path d="M215 186V204L226 214" stroke="#FDF7FF" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 838 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="91" y="126" width="118" height="92" rx="28" fill="#6750A4"/>
|
||||
<path d="M116 126V111C116 92 131 77 150 77C169 77 184 92 184 111V126" stroke="#6750A4" stroke-width="15" stroke-linecap="round"/>
|
||||
<circle cx="150" cy="169" r="10" fill="#FDF7FF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
@@ -1,37 +1,14 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<path d="M60 94V84C60 72.9543 68.9543 64 80 64H130C141.046 64 150 72.9543 150 84V94H220C231.046 94 240 102.954 240 114V210C240 221.046 231.046 230 220 230H80C68.9543 230 60 221.046 60 210V94Z"
|
||||
fill="#FDF7FF" stroke="#E8DEF8" stroke-width="2"/>
|
||||
|
||||
|
||||
<rect x="90" y="124" width="60" height="12" rx="6" fill="#E8DEF8"/>
|
||||
|
||||
|
||||
<rect x="90" y="154" width="50" height="12" rx="6" fill="#E8DEF8"/>
|
||||
|
||||
<rect x="90" y="184" width="40" height="12" rx="6" fill="#E8DEF8"/>
|
||||
<rect x="180" y="184" width="30" height="12" rx="6" fill="#E8DEF8" opacity="0.6"/>
|
||||
<circle cx="186" cy="190" r="6" fill="#E8DEF8"/>
|
||||
|
||||
<g transform="translate(210, 210)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M0 -32 C-17.67 -32 -32 -17.67 -32 0 C-32 17.67 -17.67 32 0 32 C17.67 32 32 17.67 32 0 C32 -17.67 17.67 -32 0 -32ZM0 -8 C-4.42 -8 -8 -4.42 -8 0 C-8 4.42 -4.42 8 0 8 C4.42 8 8 4.42 8 0 C8 -4.42 4.42 -8 0 -8Z"
|
||||
fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
|
||||
|
||||
<rect x="-5" y="-38" width="10" height="12" rx="3" fill="#6750A4" stroke="#FDF7FF" stroke-width="2"/>
|
||||
<rect x="-5" y="26" width="10" height="12" rx="3" fill="#6750A4" stroke="#FDF7FF" stroke-width="2"/>
|
||||
<rect x="-38" y="-5" width="12" height="10" rx="3" fill="#6750A4" stroke="#FDF7FF" stroke-width="2"/>
|
||||
<rect x="26" y="-5" width="12" height="10" rx="3" fill="#6750A4" stroke="#FDF7FF" stroke-width="2"/>
|
||||
<rect x="-5" y="-38" width="10" height="12" rx="3" fill="#6750A4" stroke="#FDF7FF" stroke-width="2"
|
||||
transform="rotate(45)"/>
|
||||
<rect x="-5" y="26" width="10" height="12" rx="3" fill="#6750A4" stroke="#FDF7FF" stroke-width="2"
|
||||
transform="rotate(45)"/>
|
||||
<rect x="-38" y="-5" width="12" height="10" rx="3" fill="#6750A4" stroke="#FDF7FF" stroke-width="2"
|
||||
transform="rotate(45)"/>
|
||||
<rect x="26" y="-5" width="12" height="10" rx="3" fill="#6750A4" stroke="#FDF7FF" stroke-width="2"
|
||||
transform="rotate(45)"/>
|
||||
|
||||
<circle cx="0" cy="0" r="22" fill="#6750A4"/>
|
||||
<circle cx="0" cy="0" r="8" fill="#FDF7FF" opacity="0.8"/>
|
||||
</g>
|
||||
</svg>
|
||||
<path d="M82 58H182L218 94V234C218 244 210 252 200 252H82C72 252 64 244 64 234V76C64 66 72 58 82 58Z" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M182 58V94H218" fill="#E8DEF8"/>
|
||||
<path d="M182 58V94H218" stroke="#C4C7C5" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M94 124H188" stroke="#6750A4" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="126" cy="124" r="12" fill="#6750A4"/>
|
||||
<path d="M94 164H188" stroke="#6750A4" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="166" cy="164" r="12" fill="#6750A4"/>
|
||||
<path d="M94 204H188" stroke="#6750A4" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="142" cy="204" r="12" fill="#6750A4"/>
|
||||
<circle cx="213" cy="218" r="33" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
|
||||
<path d="M193 220C204 207 222 207 233 220" stroke="#FDF7FF" stroke-width="7" stroke-linecap="round"/>
|
||||
<circle cx="213" cy="204" r="8" fill="#FDF7FF"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,18 +1,6 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="75" y="50" width="150" height="180" rx="24" fill="#FDF7FF" stroke="#E8DEF8" stroke-width="2"/>
|
||||
<rect x="95" y="90" width="110" height="16" rx="4" fill="#E8DEF8"/>
|
||||
<circle cx="105" cy="98" r="3" fill="#FDF7FF"/>
|
||||
<circle cx="115" cy="98" r="3" fill="#FDF7FF"/>
|
||||
<rect x="95" y="120" width="110" height="16" rx="4" fill="#E8DEF8"/>
|
||||
<circle cx="105" cy="128" r="3" fill="#FDF7FF"/>
|
||||
<circle cx="115" cy="128" r="3" fill="#FDF7FF"/>
|
||||
<rect x="95" y="150" width="80" height="16" rx="4" fill="#E8DEF8"/>
|
||||
<circle cx="105" cy="158" r="3" fill="#FDF7FF"/>
|
||||
<circle cx="115" cy="158" r="3" fill="#FDF7FF"/>
|
||||
<circle cx="195" cy="195" r="30" fill="#6750A4"/>
|
||||
<rect x="180" y="193" width="30" height="4" rx="2" fill="#FDF7FF" transform="rotate(45 195 195)"/>
|
||||
<circle cx="183" cy="183" r="4" fill="#FDF7FF"/>
|
||||
<circle cx="207" cy="207" r="4" fill="#FDF7FF"/>
|
||||
<path d="M175 158 H190 V165" stroke="#E8DEF8" stroke-width="2" stroke-linecap="round"/>
|
||||
|
||||
</svg>
|
||||
<path d="M150 205V153M150 153L96 99M150 153L204 99" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="96" cy="99" r="27" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<circle cx="204" cy="99" r="27" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<circle cx="150" cy="205" r="30" fill="#6750A4"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 466 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="96" y="72" width="145" height="156" rx="28" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<circle cx="120" cy="100" r="6" fill="#6750A4"/>
|
||||
<circle cx="140" cy="100" r="6" fill="#6750A4" opacity="0.55"/>
|
||||
<path d="M58 158H190M171 139L192 158L171 177" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 462 B |
@@ -1,31 +1,7 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<rect x="75" y="50" width="150" height="180" rx="24" fill="#FDF7FF" stroke="#E8DEF8" stroke-width="2"/>
|
||||
|
||||
|
||||
<rect x="95" y="90" width="80" height="12" rx="6" fill="#E8DEF8"/>
|
||||
|
||||
<rect x="185" y="90" width="25" height="12" rx="6" fill="#E8DEF8" opacity="0.7"/>
|
||||
|
||||
|
||||
<rect x="95" y="125" width="60" height="12" rx="6" fill="#E8DEF8"/>
|
||||
<rect x="165" y="125" width="45" height="12" rx="6" fill="#E8DEF8" opacity="0.7"/>
|
||||
|
||||
|
||||
<rect x="95" y="160" width="70" height="12" rx="6" fill="#E8DEF8"/>
|
||||
<rect x="175" y="160" width="35" height="12" rx="6" fill="#E8DEF8" opacity="0.7"/>
|
||||
|
||||
|
||||
<g transform="translate(210, 210)">
|
||||
<circle cx="0" cy="0" r="36" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
|
||||
|
||||
<circle cx="0" cy="0" r="24" stroke="#FDF7FF" stroke-width="3"/>
|
||||
|
||||
<path d="M-24 0C-24 0 -12 8 0 8C12 8 24 0 24 0" stroke="#FDF7FF" stroke-width="3" stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<ellipse cx="0" cy="0" rx="10" ry="24" stroke="#FDF7FF" stroke-width="3"/>
|
||||
|
||||
<circle cx="14" cy="-12" r="3" fill="#FDF7FF"/>
|
||||
</g>
|
||||
</svg>
|
||||
<path d="M63 69H237L174 148V221L126 246V148L63 69Z" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M95 101H205" stroke="#6750A4" stroke-width="10" stroke-linecap="round"/>
|
||||
<path d="M113 127H187" stroke="#6750A4" stroke-width="10" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="215" cy="205" r="34" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
|
||||
<path d="M199 205L210 216L231 193" stroke="#FDF7FF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 626 B |
@@ -1,19 +1,9 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="75" y="50" width="150" height="180" rx="24" fill="#FDF7FF" stroke="#E8DEF8" stroke-width="2"/>
|
||||
|
||||
<rect x="100" y="90" width="30" height="12" rx="6" fill="#E8DEF8"/>
|
||||
<rect x="136" y="90" width="50" height="12" rx="6" fill="#E8DEF8"/>
|
||||
|
||||
<rect x="120" y="120" width="80" height="12" rx="6" fill="#E8DEF8"/>
|
||||
|
||||
<rect x="120" y="150" width="50" height="12" rx="6" fill="#E8DEF8"/>
|
||||
|
||||
<rect x="100" y="180" width="20" height="12" rx="6" fill="#E8DEF8"/>
|
||||
|
||||
<g transform="translate(165, 160)">
|
||||
<rect x="0" y="0" width="80" height="80" rx="20" fill="#6750A4" stroke="#FDF7FF" stroke-width="4"/>
|
||||
<path d="M28 30L18 40L28 50" stroke="#FDF7FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M52 30L62 40L52 50" stroke="#FDF7FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M46 26L34 54" stroke="#FDF7FF" stroke-width="4" stroke-linecap="round" opacity="0.8"/>
|
||||
</g>
|
||||
</svg>
|
||||
<rect x="54" y="65" width="192" height="170" rx="30" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<path d="M54 104H246" stroke="#E8DEF8" stroke-width="18"/>
|
||||
<circle cx="82" cy="84" r="6" fill="#6750A4"/>
|
||||
<circle cx="103" cy="84" r="6" fill="#6750A4" opacity="0.55"/>
|
||||
<path d="M112 137L82 166L112 195" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M188 137L218 166L188 195" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M169 126L133 206" stroke="#6750A4" stroke-width="12" stroke-linecap="round" opacity="0.62"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 733 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M89 133C123 102 177 102 211 133" stroke="#6750A4" stroke-width="18" stroke-linecap="round"/>
|
||||
<path d="M119 172C136 157 164 157 181 172" stroke="#6750A4" stroke-width="18" stroke-linecap="round" opacity="0.62"/>
|
||||
<circle cx="150" cy="210" r="12" fill="#6750A4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |