Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68c71b8ef9 | |||
| ff23f30c31 | |||
| e55f6d1004 | |||
| 65176f6019 | |||
| 6041cd9289 | |||
| 91e524c354 | |||
| 786a6abca9 | |||
| 1e9955dec6 | |||
| e131675bdf | |||
| e62afb784a | |||
| 8378c7081c | |||
| 841d6035d5 | |||
| ee6a49713a | |||
| 46fb7fee04 | |||
| a390a42032 | |||
| bee8f24269 | |||
| 8068e686a3 | |||
| 1e97716636 | |||
| 2a2522bbc6 | |||
| 12403b063a | |||
| c7be7023d3 | |||
| 7c61c90ac2 | |||
| c5bf5bd487 | |||
| 60f371afd8 | |||
| db03880579 | |||
| d0b1562e1d | |||
| 26cfbaf456 | |||
| aaf934c2d1 | |||
| c0fcbc03ee | |||
| ae29f38f6b | |||
| adf715f8ac | |||
| c6eaa0a68b | |||
| 96fcb9ab5e | |||
| 62addf738a |
@@ -4,11 +4,12 @@ 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.
|
||||
- No `CLAUDE.md`: Claude Code v2.1.277+ loads `AGENTS.md` at startup only while no `CLAUDE.md`, `.claude/CLAUDE.md`, or `CLAUDE.local.md` sits in the working directory or above it, so adding one silently replaces `AGENTS.md` for Claude. Sessions that cannot load `AGENTS.md` directly (third-party providers, telemetry disabled) need an uncommitted `CLAUDE.local.md` containing `@AGENTS.md`. The one nested `CLAUDE.md`, in `plugins/rust_api/`, imports the root `AGENTS.md` for the same reason.
|
||||
- `.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.
|
||||
- `.agents/skills/*/SKILL.md`: repo skills shared by Codex and Claude Code. Use these for repeatable workflows that should be discoverable by name and description; `.claude/skills/` holds one symlink per skill so Claude Code finds the same files.
|
||||
- `.codex/config.toml`: trusted project Codex settings such as MCP, hooks, sandbox, approval, or model defaults.
|
||||
- `.codex/rules/*.rules`: command permission rules for running commands outside the sandbox. Do not mix these with coding conventions in `.agents/rules.md`.
|
||||
- `.codex/rules/*.rules`: command permission rules for running commands outside the sandbox. None exist yet. Do not mix these with coding conventions in `.agents/rules.md`.
|
||||
- `.codex/hooks.json` or `.codex/config.toml` hooks: lifecycle automation such as prompt checks, command validation, or final verification gates.
|
||||
|
||||
## Placement Rules
|
||||
@@ -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 the one-line form of a rule in every context that must obey it, and keep its explanation in one `.agents/*.md` file that the others link to. Prefer a runnable check to either: 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
|
||||
|
||||
+811
-113
File diff suppressed because it is too large
Load Diff
+214
-28
@@ -17,26 +17,49 @@ 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 `setup` to `false` with `yq` before `dart run tool/changelog.dart` and
|
||||
`flutter test`, and restores the file afterwards; no test loads the Go core. `rust_api`
|
||||
stays on there because the editor tests in `test/plugins/code_forge/` load the Rust
|
||||
library the hook builds; the release-notes job, which runs no tests, flips both.
|
||||
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. The editor tests still need the Rust library: keep `rust_api`
|
||||
on, or point `FRB_DART_LOAD_EXTERNAL_LIBRARY_NATIVE_LIB_DIR` at a directory that
|
||||
holds a `rust_api` library built from the same sources.
|
||||
|
||||
## Flutter Development
|
||||
|
||||
@@ -50,6 +73,23 @@ flutter test
|
||||
|
||||
Use `flutter test`, not `dart test`, because models pull in Flutter types.
|
||||
|
||||
A development build takes over the host exactly like a release: it creates the TUN device, sets the system proxy, on
|
||||
macOS rewrites the system DNS, registers itself as the login item, and on Windows and Linux starts its Core through the
|
||||
shared privileged Helper, which stops the Core an installed FlClash is running. Only the data directory is kept apart,
|
||||
and only for some builds: a macOS debug build is `com.follow.clash.debug` and an Android debug build `com.follow.clash.dev`,
|
||||
while profile and release builds, and every build on Windows and Linux, share the directory and preferences of the
|
||||
installed app. Safe mode keeps all of the host changes off and never opens the Core's listeners or external
|
||||
controller, so it does not compete with the installed app for the mixed, DNS, RESTful API and other loopback ports:
|
||||
|
||||
```bash
|
||||
flutter run --dart-define-from-file=env.json --dart-define=SAFE_MODE=true
|
||||
```
|
||||
|
||||
`.run/main_safe_mode.run.xml` is the same launch for IntelliJ, and `Flutter App (Safe Mode)` in `.zed/debug.json` (debugger) or `flutter run (Safe Mode)` in `.zed/tasks.json`
|
||||
(plain run) for Zed. The build is bannered `SAFE MODE`, the tray shows the
|
||||
`status_4` bug icon with a menu of only show and exit and no traffic title, and the settings keep their values, so
|
||||
nothing has to be flipped back before a normal run. See the safe mode section of `architecture.md` for the owners that consult the flag.
|
||||
|
||||
## Code Generation
|
||||
|
||||
Run code generation after modifying models, providers, or database schema:
|
||||
@@ -65,12 +105,37 @@ Code generation covers:
|
||||
- Models through `freezed` and `json_serializable`.
|
||||
- Database tables through `drift_dev`.
|
||||
|
||||
The Dart-Rust bindings in `plugins/rust_api` are generated by `flutter_rust_bridge_codegen` from
|
||||
`plugins/rust_api/flutter_rust_bridge.yaml`; rerun it from that directory after changing anything under
|
||||
`rust/src/api/`:
|
||||
|
||||
```bash
|
||||
cd plugins/rust_api && flutter_rust_bridge_codegen generate
|
||||
```
|
||||
|
||||
Generated output paths, configured in `build.yaml`:
|
||||
|
||||
- `lib/models/generated/*.g.dart`, `*.freezed.dart`.
|
||||
- `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`. macOS draws its menu bar icon as a template, so it skips the ringed color
|
||||
icons, whose logo shrinks to a small glyph once only alpha shows: `status_1` renders the bare logo from
|
||||
`assets_source/images/icon/glyph.svg` and `status_4` reuses `status_4.svg`, both recolored to black into
|
||||
`assets/images/tray/macos/`; the sources keep their colors. `glyph.svg` is also the geometry of the Android notification drawables
|
||||
`android/service/src/main/res/drawable/ic.xml` and `ic_service.xml`; change them together. `pubspec.yaml` declares the three 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 +152,14 @@ 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.
|
||||
|
||||
Native plugin code under `plugins/*/{linux,macos,windows}` is compiled by the CI `desktop` job, which runs
|
||||
`flutter build <platform> --debug` on every push; `tool/check_plugins.sh` only analyzes and tests the Dart side.
|
||||
|
||||
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:
|
||||
|
||||
@@ -131,32 +203,146 @@ cargo test --manifest-path services/helper/Cargo.toml --features windows-service
|
||||
```
|
||||
|
||||
The last command requires Windows for meaningful service coverage. Native Android lifecycle edits should at minimum
|
||||
compile the modules they touch; use JDK 17 in this checkout:
|
||||
compile the modules they touch, with the JDK 17 that CI installs. On macOS the Homebrew `openjdk@17` keg is not
|
||||
registered with `java_home`, so export it by prefix:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew :service:compileDebugKotlin
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew :app:compileDebugKotlin
|
||||
export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home" # elsewhere: any JDK 17
|
||||
./gradlew :service:compileDebugKotlin
|
||||
./gradlew :app:compileDebugKotlin
|
||||
```
|
||||
|
||||
Always-on VPN entry, system VPN revoke, actual permission UI, and rapid device start/stop still require Android device or
|
||||
emulator validation; Kotlin compilation cannot prove those system callbacks.
|
||||
|
||||
## Verify
|
||||
## Changelog And Release
|
||||
|
||||
The tag-triggered release workflow runs these root-package checks in order:
|
||||
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
|
||||
flutter pub get
|
||||
flutter analyze --no-fatal-infos
|
||||
flutter test --reporter expanded
|
||||
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
|
||||
```
|
||||
|
||||
Run `flutter analyze` locally before committing when practical.
|
||||
Releasing a stable version, in order:
|
||||
|
||||
The workflow runs only for `v*` tag pushes; pull requests do not trigger it.
|
||||
```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.96` 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.
|
||||
|
||||
### Website
|
||||
|
||||
`site/` is the GitHub Pages site: a static page that renders `CHANGELOG.md` and the download links in the browser, and
|
||||
works as is for local previews. `tool/build_site.sh [dir]` assembles it with the preview images, the app icon,
|
||||
`CHANGELOG.md` read at the latest release tag, and a `release.json` of asset names, sizes and digests fetched through
|
||||
`gh`. Without `gh` access the page falls back to the newest version in `CHANGELOG.md` and lists the files without sizes.
|
||||
It then runs `tool/render_site.mjs` (Node), which writes the English page and a `zh/` copy for search engines: page
|
||||
text, the first changelog releases, canonical and `hreflang` links, JSON-LD and `sitemap.xml`, all from the strings,
|
||||
changelog parser and release markup in `site/assets/shared.js`. `SITE_URL` overrides the published URL these point at.
|
||||
On the built site each URL keeps its language: the language button links to the other page and carries the section in
|
||||
view, the chosen platform and the changelog search. Only the English root sends readers who chose or prefer Chinese on
|
||||
to `zh/`, unless its URL carries `?lang=en`: the button on `zh/` links there with it, so getting back to English never
|
||||
depends on browser storage. A project site cannot serve the `robots.txt` that would announce the sitemap, so submit
|
||||
`sitemap.xml` in Google Search Console and Bing Webmaster Tools once.
|
||||
|
||||
```bash
|
||||
tool/build_site.sh && python3 -m http.server -d build/site
|
||||
```
|
||||
|
||||
The page's look is deliberate: low-saturation flat colour, no blur, glow, glass or soft shadow, and motion that follows
|
||||
the logo's 30° lattice. `site/assets/field.js` draws the hero's capsules on that lattice with the glyph's own
|
||||
proportions, and the slanted wipes, the dot-to-bar marks and the theme transition reuse the same angle. Text that sits
|
||||
inside the hero has to be listed in `wireField` in `app.js`, or the capsules run behind it.
|
||||
|
||||
`.github/workflows/pages.yaml` deploys it after every successful stable tag build and on pushes to `main` that touch the
|
||||
site, in the public repository only; its Pages source has to be set to GitHub Actions once. The file names come from the
|
||||
release matrix in `build.yaml`, so a renamed artifact needs the same change in `buildsFor` in `site/assets/app.js`.
|
||||
|
||||
## Verify
|
||||
|
||||
In the public repository every branch push runs the `dart` job; in the private
|
||||
`origin` every check job runs only when started by hand (`workflow_dispatch`).
|
||||
The `dart` job performs these root-package checks in order:
|
||||
|
||||
```bash
|
||||
bash tool/check_commit_msg_test.sh
|
||||
bash tool/check_comment_density_test.sh
|
||||
flutter pub get
|
||||
dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart
|
||||
flutter analyze --no-fatal-infos
|
||||
dart run tool/changelog.dart verify # main and tags only
|
||||
flutter test --reporter expanded --coverage
|
||||
dart run tool/check_coverage.dart coverage/lcov.info 75
|
||||
```
|
||||
|
||||
Run `flutter analyze` before committing; CI fails on anything it reports.
|
||||
|
||||
Release builds run only for `v*` tag pushes; pull requests trigger nothing.
|
||||
Root analysis excludes `plugins/**`, and root tests do not discover nested
|
||||
plugin packages, so CI also validates local Flutter packages, the setup build
|
||||
tool, the Go wrapper, and Rust components from their own package directories. A
|
||||
separate Windows runner compiles and tests the helper's `windows-service`
|
||||
feature before release builds can start.
|
||||
plugin packages, so parallel jobs validate the rest from their own package
|
||||
directories: `plugins` (local Flutter packages and the setup build tool), `go`
|
||||
(the Core wrapper, plus an NDK-backed vet of the Android files), `android`
|
||||
(JVM unit tests for `:common`, `:service` and `:app`, with the Flutter compile
|
||||
tasks excluded so no native build hook runs), `rust` (both crates), and a
|
||||
Windows runner for the helper's `windows-service` feature. A `desktop` job also
|
||||
compiles a debug build per desktop platform. Release builds run only in the
|
||||
public repository and start once every job except `desktop` passes.
|
||||
|
||||
`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
|
||||
```
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Flutter Upgrade Checklist
|
||||
|
||||
Read this before changing `FLUTTER_VERSION` in `.github/workflows/build.yaml` or `.fvmrc`. Each entry is a
|
||||
workaround tied to a Flutter bug: what it does, where it lives, and what has to be true before it can go. Remove an
|
||||
entry together with its workaround; add one whenever a new workaround is pinned to the SDK.
|
||||
|
||||
## Stable cadence
|
||||
|
||||
Flutter ships a stable release about once a quarter, three minor versions apart (3.41 in February, 3.44 in May, 3.47
|
||||
in August 2026). The two minors in between (3.48, 3.49) are beta only, so a fix that lands on `main` reaches stable
|
||||
in the next multiple, 3.50, expected around mid-November 2026. Hotfix releases (3.47.x) carry only cherry-picks
|
||||
listed in the SDK's `CHANGELOG.md`.
|
||||
|
||||
## Impeller disabled on Intel macOS release builds
|
||||
|
||||
- **Bug:** [flutter/flutter#191538](https://github.com/flutter/flutter/issues/191538). From 3.47, when Impeller
|
||||
became the default desktop renderer, Intel (x86_64) Macs flicker across the whole window while scrolling or
|
||||
animating, and text can render garbled. Apple Silicon is not affected.
|
||||
- **Fix upstream:** landed on `main` before 2026-08-25 and verified on `3.48.0-1.0.pre-404`; it is not in any
|
||||
3.47.x hotfix (checked through 3.47.5).
|
||||
- **Workaround:** the release job for `macos-15-intel` (`arch: amd64`) runs
|
||||
`plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist` before `dart setup.dart`, so only the Intel
|
||||
DMG falls back to Skia. The checked-in `Info.plist` does not set the key, so local builds and the arm64 DMG keep
|
||||
Impeller. The macOS embedder reads the key at launch in `FlutterDartProject.mm` (`enableImpeller`), release
|
||||
builds included.
|
||||
- **Side effect:** Skia on Intel renders blur, shadows and text slightly differently; judge Intel-only visual reports
|
||||
against Skia, not Impeller.
|
||||
- **Remove when:** `FLUTTER_VERSION` moves to a stable that contains the fix (3.50 or later, or a 3.47.x whose
|
||||
changelog lists #191538). Before dropping the step, build the Intel DMG without it and scroll the proxies and
|
||||
dashboard pages on an Intel Mac with its integrated GPU driving the built-in display; an external display can
|
||||
switch to the discrete GPU and hide the flicker.
|
||||
|
||||
## Windows view visibility forwarded by the runner
|
||||
|
||||
- **Bug:** the Windows embedder's `WindowsLifecycleManager` counts the Flutter view as a window of its own and keeps
|
||||
it visible until the view is sized to zero. Hiding the top-level window never resizes the view, and the `window`
|
||||
plugin keeps it sized while minimized, so the app was never told `hidden`: animations kept rendering in the tray
|
||||
and `appVisibleProvider` stayed true.
|
||||
- **Workaround:** `WindowPluginForwardVisibility` in the `window` plugin's
|
||||
`windows/include/window/window_plugin_runner.h` hands the top-level window's `WM_SHOWWINDOW` and `WM_SIZE` to
|
||||
`ProcessExternalWindowMessage` for the view's HWND, so the view shows and hides with its window.
|
||||
`FlutterWindow::MessageHandler` in `windows/runner/flutter_window.cpp` calls it before `HandleTopLevelWindowProc`;
|
||||
the Flutter runner template has no such call, so keep it when regenerating the runner.
|
||||
- **Remove when:** the engine hides the view together with its top-level window. Check with the proxy running and
|
||||
the window focused: hiding it to the tray and minimizing it must each log `AppLifecycleState.hidden`.
|
||||
|
||||
## macOS activation resumes a hidden window
|
||||
|
||||
- **Bug:** `handleWillBecomeActive` in the macOS engine's `FlutterEngine.mm` resumes when any window in
|
||||
`NSApp.windows` is visible, and the tray's `NSStatusBarWindow` always is. Activating the app while its window was
|
||||
minimized or hidden, such as Cmd-Tab back to a minimized window, reported `resumed`, and the next deactivation
|
||||
`inactive`, so frames ran until the window was shown again.
|
||||
- **Workaround:** the `window` plugin observes will-become-active and will-resign-active and, once the engine has
|
||||
answered, sends `AppLifecycleState.hidden` on `flutter/lifecycle` while its window is not visible
|
||||
(`applicationActivityChanged` in `WindowPlugin.swift`). A window brought back without another activation change
|
||||
would then wait on an occlusion notification macOS does not always post, keeping its last frame, so `show` and a
|
||||
deminiaturize hand the state back (`windowBecameVisible`).
|
||||
- **Remove when:** the engine ignores status item windows on activation. Check without the workaround: minimize the
|
||||
window, switch to another app, then Cmd-Tab back without restoring it; the log must stay at
|
||||
`AppLifecycleState.hidden`.
|
||||
|
||||
## Linux minimize on Wayland reads as inactive
|
||||
|
||||
- **Bug:** the Linux engine reports `hidden` for `GDK_WINDOW_STATE_ICONIFIED`, but xdg-shell has no minimized state,
|
||||
so GTK 3 never sets it on Wayland. A minimized window reported only its focus loss, as `inactive`, and kept
|
||||
rendering at the display rate (GNOME 46).
|
||||
- **Workaround:** the `window` plugin's Linux `minimize` records the time, and a focus loss within 500 ms of it sends
|
||||
`AppLifecycleState.hidden`. Its `window-state-event` handler is connected after the engine's, so that message
|
||||
follows the engine's `inactive`; the compositor focuses the window when it restores it, which the engine reports as
|
||||
`resumed`. A minimize the compositor starts itself (Super+H, the dock) is not covered.
|
||||
- **Remove when:** the Linux engine reports minimized windows on Wayland, which needs GTK 4 or xdg-shell's
|
||||
`suspended` state. Check in a GNOME Wayland session without the workaround: minimizing from the caption button
|
||||
must log `AppLifecycleState.hidden`.
|
||||
+77
-5
@@ -4,22 +4,94 @@ 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
|
||||
version as the source of truth for release builds.
|
||||
- Dart SDK constraint: `>=3.8.0 <4.0.0`.
|
||||
- Flutter stays on the 3.47.x line. Release CI pins 3.47.4 and is the source
|
||||
of truth for release builds; `.fvmrc` and local SDKs may sit on another 3.47
|
||||
patch. The editor in `plugins/code_forge` came from `code_forge` 10.14.0, which
|
||||
needs Dart 3.13.2, so the CI pin cannot go below Flutter 3.47.2.
|
||||
- Before moving the pin, walk `flutter-upgrade.md`: it lists the workarounds tied to
|
||||
the current SDK, such as Impeller disabled on the Intel macOS release build.
|
||||
- The Dart SDK lower bound in `pubspec.yaml` sets the package language
|
||||
version. `flutter analyze` excludes `lib/**/generated/**`, so a raise is
|
||||
proven by `flutter test`, which parses the generated code, not by analyze.
|
||||
|
||||
## Forked Dependencies
|
||||
|
||||
Two `pubspec.yaml` dependencies are pinned to a fork by commit SHA. Both 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>
|
||||
```
|
||||
|
||||
The `window_manager` fork (`chen08209/window_manager`, tag `v0.5.1-flclash.3`)
|
||||
was replaced by the in-repo `plugins/window` package; the behaviors it carried
|
||||
(work-area maximize with a hidden title bar on Windows, geometry restore on
|
||||
re-map on Linux, corner preference, terminate and activate events) live there
|
||||
now, see the window plugin section of `architecture.md`.
|
||||
|
||||
`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.
|
||||
|
||||
The `code_forge` fork (`chen08209/code_forge`) is frozen: its Dart code now lives
|
||||
in the in-repo `plugins/code_forge` package and its Rust code in the `editor/`
|
||||
module of `plugins/rust_api`, both trimmed to what FlClash uses.
|
||||
`plugins/code_forge/README.md` records what was kept and what was removed;
|
||||
upstream's MIT notice stays in `plugins/code_forge/LICENSE`.
|
||||
|
||||
## Dependency Ceilings
|
||||
|
||||
`flutter pub outdated` lists only upstream-held entries, so treat them as
|
||||
resolved-until-the-ceiling-moves rather than as debt:
|
||||
|
||||
- `intl` is intentionally unbounded (`any`) and `material_color_utilities` is
|
||||
resolved by the Flutter SDK; neither is a bound this repository sets.
|
||||
|
||||
`freezed`, the `riverpod` set, `build_runner`, `drift_dev`, `intl_utils` and
|
||||
`test` all resolve against the analyzer the pinned SDK ships; none of them is
|
||||
held back. When an upgrade fails version solving, record the blocker here with
|
||||
what has to change upstream before the pin can move, so the next
|
||||
`flutter pub outdated` run does not rediscover it.
|
||||
|
||||
`_initDynamicColor` in `lib/bootstrap.dart` reads the `dynamic_color` method
|
||||
channel directly and decodes the palette list into a `TonalPalette`, instead of
|
||||
going through `DynamicColorPlugin.getCorePalette()` and its deprecated
|
||||
`CorePalette` return type. Everything downstream sees `DynamicColorSeeds`, so a
|
||||
non-deprecated plugin accessor changes only that one function.
|
||||
|
||||
## 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:
|
||||
|
||||
|
||||
+446
-30
@@ -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,65 +17,241 @@ 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`.
|
||||
|
||||
Generated directories are excluded from analysis:
|
||||
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 (continuous corners) everywhere, never circular arcs; when a component offers
|
||||
both, pick the superellipse. `test/lint/superellipse_corners_test.dart` fails on the circular APIs below in `lib/`. 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`.
|
||||
- Ink: `InkWell.customBorder` with an `AppShape` instead of `InkWell.borderRadius`.
|
||||
|
||||
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.
|
||||
|
||||
Pills are no exception. `StadiumBorder` joins its round caps to the straight sides with a curvature jump, while
|
||||
`AppShape.full`, a superellipse at full radius, blends them, and on a square it renders the exact circle, so it serves
|
||||
circles too. Material 3 defaults every button, icon button, segmented button, and search bar to `StadiumBorder`;
|
||||
`ThemeData.withAppShapes` replaces that for the button themes, and a local `FilledButtonTheme` or `IconButtonTheme`
|
||||
replaces the app's, so merge the ambient `XButtonTheme.of(context).style` into it, as `CommonMinFilledButtonTheme`
|
||||
does. `CircleBorder` and `BoxShape.circle` are fine for something that is always square.
|
||||
|
||||
APIs that accept only a `Radius`, such as `ScrollbarThemeData.radius`, keep circular corners; supply the superellipse
|
||||
with an enclosing clip or shape where it shows.
|
||||
|
||||
CI gates formatting: `dart format --output=none --set-exit-if-changed lib test
|
||||
tool plugins setup.dart` runs before `flutter analyze`.
|
||||
|
||||
These directories are excluded from analysis:
|
||||
|
||||
- `build/**`
|
||||
- `lib/l10n/intl/**`
|
||||
- `lib/**/generated/**`
|
||||
- `plugins/**`
|
||||
- the platform runner directories: `android/`, `ios/`, `web/`, `windows/`, `macos/`, `linux/`
|
||||
|
||||
## 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,
|
||||
route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest
|
||||
entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push
|
||||
out the request events the connection list is built from; 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. A half-written frame whose write merely timed out is
|
||||
resumed for as long as the stall lasts: Windows Modern Standby suspends the app while the Helper's Core keeps running,
|
||||
so the host can stop draining for hours and still come back, and go-winio reports the expiry as its own `ErrTimeout`
|
||||
rather than `os.ErrDeadlineExceeded`, which is why `send` checks `Timeout()`. The wait has no cap on purpose, and
|
||||
`send` holds `writeMu` throughout, so every other frame — method responses and the single batcher goroutine behind
|
||||
the event queues — waits behind the stalled one. Memory is bounded by the queues; what gives is delivery: the state
|
||||
queue fills and `enqueueState` starts dropping, which is the case the `UpdatingAction` sweep above exists for. Nothing
|
||||
on the Dart side restarts the Core over a stall: `CoreRpcClient` times each pending request out on its own and hands
|
||||
the caller `null` (a `no_response` exception for message methods), and the sweep clears core-scope updating state
|
||||
minutes later. Only a half-written frame that fails outright 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`.
|
||||
- Queueing for the delay-test semaphore and probing the node each get the full timeout in `handleTestDelay`. With one
|
||||
shared deadline, a node that waited behind a saturated semaphore reached `URLTest` with almost nothing left, so a bulk
|
||||
test of a large subscription reported whatever sat at the back of the queue as unreachable.
|
||||
- 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 within its two timeouts 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 `go` 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
|
||||
@@ -82,21 +262,166 @@ the invariant hard to break beats prose that asks the next reader not to break i
|
||||
actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.
|
||||
- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the
|
||||
broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.
|
||||
- Anything that probes through the outbound route is a `RoutedProbe` judged against `routeTrackerProvider` (Route
|
||||
Consistency in `.agents/architecture.md`); the Core stamps every answer and publishes its picks, so do not add a
|
||||
change signal, a trigger counter, or a `coreStatusProvider` watch of your own.
|
||||
- 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.
|
||||
- Safe mode (`SAFE_MODE` define, see the safe mode section of `.agents/architecture.md`) is consulted by each host
|
||||
integration at its owner, never by rewriting the user's settings. `safeModeBuild` is for handles created before the
|
||||
container exists; everything else reads `safeModeProvider` so a test can override it.
|
||||
- `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.
|
||||
|
||||
Use `CoreController.test(mock)` to inject a mocked `CoreHandlerInterface`. Call `CoreController.resetInstance()` in `tearDown` to clean up the singleton between tests.
|
||||
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.
|
||||
|
||||
Register fallback values for freezed params used with `any()` matchers.
|
||||
|
||||
Use `ProviderContainer` directly for simple Riverpod provider tests. The generated Riverpod `update()` method takes a callback:
|
||||
`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. A test that does claim
|
||||
the singleton calls `CoreController.resetInstance()` in `tearDown`.
|
||||
|
||||
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 `EditorView` (what `EditorPage` embeds): 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.
|
||||
|
||||
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` 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, released with `addTearDown(container.dispose)`.
|
||||
Assert against the provider's generated public API, not its implementation, re-read source defaults before asserting
|
||||
them, and wait on provider futures or state changes rather than fixed sleeps. The generated Riverpod `update()` method
|
||||
takes a callback:
|
||||
|
||||
```dart
|
||||
notifier.update((state) => newValue);
|
||||
@@ -107,6 +432,77 @@ 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.
|
||||
|
||||
## Running the App Locally
|
||||
|
||||
- An agent starts FlClash on the host only as a safe mode build (`--dart-define=SAFE_MODE=true`, see
|
||||
`.agents/commands.md`). A normal build takes over the host network, so the user runs it, never the agent.
|
||||
- Never end a FlClash process the agent did not start, and never kill by name (`pkill`, `killall`, a pattern on the
|
||||
executable path): the user's own instance matches too. Record the pid of every process you launch, check with
|
||||
`ps -o pid,command -p <pid>` that its executable is your own build output, and end only that pid.
|
||||
- Only a macOS or Android debug build has its own data directory; every other build shares the installed app's data
|
||||
directory and single-instance lock, so it cannot start while the user's instance runs. `.agents/commands.md` lists
|
||||
which builds are kept apart.
|
||||
|
||||
## 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`.
|
||||
|
||||
### Fixup Commits
|
||||
|
||||
A follow-up to a change that has not shipped in a stable release goes in as `git commit --fixup=<target>`, aimed at the
|
||||
commit that introduced what it amends.
|
||||
|
||||
- Only stable tags (`vX.Y.Z`) close a commit. A target reached only by pre-release tags (`vX.Y.Z-pre.N`) is still open
|
||||
for fixups, so do not steer away from it or flag it.
|
||||
- A commit a stable tag contains takes no fixup; land the follow-up as a regular commit. Check with
|
||||
`git tag --contains <target> | grep -v -- '-pre'`, which must print nothing.
|
||||
- Asking for a fixup means creating the `fixup!` commit only. Do not run `git rebase --autosquash` or otherwise fold it
|
||||
into its target unless the user asks for the squash.
|
||||
|
||||
### 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. A `Changelog-<locale>:` or `Breaking-<locale>:` trailer fails the hook, because
|
||||
translated release copy does not belong in the commit history. 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; for `feat`, `fix` and `perf` the hook
|
||||
says so without blocking.
|
||||
|
||||
## Generated Code
|
||||
|
||||
Do not manually edit generated files under:
|
||||
@@ -118,3 +514,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.
|
||||
|
||||
+9
-3
@@ -1,18 +1,24 @@
|
||||
# Agent Skills
|
||||
|
||||
Repo-scoped Codex skills live under `.agents/skills/*/SKILL.md`. Codex can discover these by skill `name` and `description`, then load the full instructions only when a task matches.
|
||||
Repo skills live under `.agents/skills/*/SKILL.md`, where Codex discovers them directly. Claude Code discovers them
|
||||
through the symlinks in `.claude/skills/`, one per skill, pointing back at the `.agents/skills/` directory. Both tools
|
||||
match a task against the skill `name` and `description`, then load the full instructions only when it matches.
|
||||
|
||||
## Available Repo Skills
|
||||
|
||||
- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.
|
||||
- `provider-tests`: Riverpod provider, notifier, and state-management tests.
|
||||
- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, async feedback, and user-facing interactions.
|
||||
- `core-platform`: Core lifecycle/process ownership, Android services, Go event delivery, desktop IPC, platform managers,
|
||||
VPN/TUN, and Windows Helper flow.
|
||||
- `pre-commit-quality-review`: lightweight triage before commits, with scoped structural review and user decisions on concrete regressions.
|
||||
|
||||
## Authoring Notes
|
||||
|
||||
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`.
|
||||
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`, and add the matching
|
||||
`.claude/skills/<skill-name>` symlink so Claude Code sees it too; remove both when retiring a skill.
|
||||
- Git stores the symlinks as links only where the checkout supports them. A Windows checkout needs
|
||||
`core.symlinks=true` and Developer Mode, otherwise the links become plain files and Claude Code loads no repo
|
||||
skill there; Codex is unaffected because it reads `.agents/skills/` directly.
|
||||
- Keep skill descriptions trigger-focused and start them with `Use when...`.
|
||||
- Keep long reference material in `.agents/*.md`; skills should link to it instead of duplicating it.
|
||||
- Put command permission rules in `.codex/rules/*.rules`, not in this file.
|
||||
|
||||
@@ -1,73 +1,63 @@
|
||||
---
|
||||
name: core-platform
|
||||
description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, VPN/TUN, or Windows Helper flow.
|
||||
description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, build hooks, system proxy, tray, VPN/TUN, or the Windows or Linux Helper.
|
||||
---
|
||||
|
||||
# Core And Platform
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules, build
|
||||
hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
|
||||
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules,
|
||||
build hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
|
||||
|
||||
## Workflow
|
||||
## Read First
|
||||
|
||||
1. Identify the authoritative owner before changing behavior:
|
||||
- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.
|
||||
- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.
|
||||
- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.
|
||||
- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.
|
||||
- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.
|
||||
- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows Helper HTTP contract:
|
||||
`lib/core/desktop/helper_client.dart` and `services/helper/`.
|
||||
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; UI/event observation: `lib/manager/`.
|
||||
2. Trace every entry path into that owner, including UI/provider calls, Quick Settings, notification actions, Always-on VPN,
|
||||
revoke callbacks, application exit, and crash/disconnect recovery. Lifecycle callbacks are not implicit user intent.
|
||||
3. Preserve latest-intent semantics:
|
||||
- Desktop revisions converge to running/restarted/stopped/closed and report applied/coalesced/superseded outcomes.
|
||||
- Android Flutter calls stay optimistic; `ServiceState` identity-checks the latest native `RunRequest`.
|
||||
4. Route feature calls through `CoreController` and `CoreHandlerInterface`. Do not bypass desktop process leases or create a
|
||||
second Android service binding owner.
|
||||
5. Keep JSON envelopes and event shapes identical across Dart, Go, JNI, and desktop IPC. If event traffic changes, preserve
|
||||
the separate priority and bulk queues in `core/message.go`.
|
||||
6. Keep shutdown single-owned and terminal. `SystemExitCoordinator` sequences resource cleanup, window close, Core close,
|
||||
and process exit; widget/manager disposal must not race it.
|
||||
7. Add or update focused tests at the narrowest layer, then run the matching commands from `.agents/commands.md`:
|
||||
- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.
|
||||
- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.
|
||||
- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.
|
||||
- Android Kotlin: compile each touched Gradle module with JDK 17.
|
||||
- Windows Helper: Cargo format/tests; run the `windows-service` feature on Windows.
|
||||
8. Explicitly state host gaps. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, and Windows Service
|
||||
Control Manager behavior need their real platform even when portable tests pass.
|
||||
- `.agents/rules.md`: Core API Safety and Lifecycle Rules are binding. They carry the mihomo-fork patch budget, the
|
||||
JSON envelope and `core/message.go` queue rules, JNI ownership, `BootGuard`, tray, and SSID rules, and every
|
||||
lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,
|
||||
optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).
|
||||
- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,
|
||||
Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,
|
||||
the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.
|
||||
|
||||
## Reference Files
|
||||
## Owners
|
||||
|
||||
Read `.agents/architecture.md` for the current core modes, manager stack, build hooks, local plugins, and Windows helper notes.
|
||||
Every behavior here has one authoritative owner, and a change lands there:
|
||||
|
||||
- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.
|
||||
- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.
|
||||
- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.
|
||||
- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.
|
||||
- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.
|
||||
- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows and Linux Helper HTTP contract:
|
||||
`lib/core/desktop/helper_client.dart` and `services/helper/`.
|
||||
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; application exit:
|
||||
`SystemExitCoordinator`; UI/event observation: `lib/manager/`.
|
||||
|
||||
An owner has more entry paths than the UI: provider calls, Quick Settings, notification actions, Always-on VPN, revoke
|
||||
callbacks, application exit, and crash/disconnect recovery all reach it. Always-on startup is explicit through
|
||||
`VPN_START_REQUESTED` and revoke through `VPN_REVOKED`; service creation or destruction is never read as intent.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused tests live at the narrowest layer, and the matching commands are in `.agents/commands.md`:
|
||||
|
||||
- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.
|
||||
- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.
|
||||
- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.
|
||||
- Android Kotlin: compile each touched Gradle module with JDK 17.
|
||||
- Helper: `cargo fmt --check` and `cargo test`; the `windows-service` feature only builds on Windows.
|
||||
|
||||
State host gaps explicitly. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, polkit and systemd
|
||||
on Linux, and Windows Service Control Manager behavior need their real platform even when portable tests pass.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Keep the Windows Helper protocol and Core SHA256 validation identical across
|
||||
Flutter build modes; the Helper owns executable integrity checks.
|
||||
- Protocol version 6 uses a 32-character lowercase-hex session ID. `/start` must return the submitted session and PID;
|
||||
`/stop` must never terminate a different session; Dart must verify the connected named-pipe peer PID.
|
||||
- `/start` must release the previously managed Core before it verifies, so no `/start` outcome leaves a Helper-managed
|
||||
Core behind for the caller's direct-launch fallback to race.
|
||||
- The Helper owns a managed Core until its exit is confirmed. A `200` from `/stop` means the Core is gone; when
|
||||
termination cannot be confirmed the Helper keeps the child and answers `coreStopFailed`, and `/start` reports the same
|
||||
code instead of spawning a replacement. Keep that code out of the Dart pre-spawn fallback set in
|
||||
`helper_client.dart`, or the direct launch will race a Core the Helper still owns.
|
||||
- TUN is not a required run condition. Degrading to the unelevated direct Core — and silently losing TUN — is the
|
||||
expected outcome whenever the Helper path fails; do not fail the launch instead.
|
||||
- A desktop process lease with unconfirmed exit must remain owned until cleanup succeeds. Do not discard it and start a
|
||||
replacement Core.
|
||||
- `CoreController.close()` is terminal. Do not call it from a reusable manager lifecycle or recover by starting it again.
|
||||
- `ServiceBroadcastReceiver.goAsync()` must finish once even on timeout; its watchdog releases the broadcast only and must
|
||||
not become a service timeout.
|
||||
- Do not interpret service creation/destruction as start/stop intent. Always-on startup is explicit through
|
||||
`VPN_START_REQUESTED`; revoke is explicit through `VPN_REVOKED`.
|
||||
- Keep log/request floods from evicting state-bearing Core events. Each queue may evict only its own oldest item.
|
||||
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
|
||||
a scope-specific cleanup API instead.
|
||||
- `plugins/setup/` is a build harness, not a Dart API plugin.
|
||||
- Build hooks can trigger Go or Rust compilation indirectly through Flutter platform builds.
|
||||
- A `200` from Helper `/stop` means the Core is gone. When termination cannot be confirmed the Helper keeps the child
|
||||
and answers `coreStopFailed`, and `/start` reports the same code instead of spawning a replacement. Keep that code
|
||||
out of the Dart pre-spawn fallback set in `helper_client.dart`, or the direct launch races a Core the Helper still
|
||||
owns.
|
||||
- The Helper owns executable integrity: Core SHA256 validation and the protocol stay identical across Flutter build
|
||||
modes, so a debug build is not exempt.
|
||||
- `plugins/setup/` is a build harness, not a Dart API plugin, and its hooks can trigger Go or Rust compilation
|
||||
indirectly through Flutter platform builds; a missing toolchain fails `flutter test`, not only `flutter build`.
|
||||
|
||||
@@ -7,39 +7,39 @@ 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.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm `pubspec.yaml` still uses `flutter_intl`, source ARBs under `arb/`, and generated output under `lib/l10n/`.
|
||||
2. Scan user-facing Dart text before opening many files:
|
||||
1. Scan user-facing Dart text before opening many files. `flutter_intl` reads `arb/` and writes `lib/l10n/`:
|
||||
|
||||
```bash
|
||||
rg -n "[\\p{Han}]" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'
|
||||
```
|
||||
|
||||
3. Inspect the smallest relevant call sites and nearby ARB keys.
|
||||
4. Add or update every source ARB:
|
||||
2. Inspect the smallest relevant call sites and nearby ARB keys.
|
||||
3. Add or update every source ARB:
|
||||
- `arb/intl_en.arb`
|
||||
- `arb/intl_zh_CN.arb`
|
||||
- `arb/intl_ja.arb`
|
||||
- `arb/intl_ru.arb`
|
||||
5. Replace inline strings with existing project accessors:
|
||||
- Widgets with `BuildContext`: `context.appLocalizations.key` from `common.dart`.
|
||||
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `app_localizations.dart`.
|
||||
6. Regenerate:
|
||||
4. Replace inline strings with existing project accessors:
|
||||
- Widgets with `BuildContext`: `context.appLocalizations.key` from `lib/common/context.dart`.
|
||||
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `lib/common/app_localizations.dart`.
|
||||
5. Regenerate:
|
||||
|
||||
```bash
|
||||
dart run intl_utils:generate
|
||||
```
|
||||
|
||||
7. Verify changed Dart files with `flutter analyze` when practical.
|
||||
8. Re-run the Han-text scan for targeted strings.
|
||||
6. Run `flutter analyze --no-fatal-infos` and `flutter test test/l10n test/lint/dynamic_message_key_test.dart`. The
|
||||
lint test covers the labels built from a runtime key, such as `action_${HotAction.name}`, which the analyzer cannot
|
||||
see; the ARB section of `.agents/rules.md` explains them.
|
||||
7. Re-run the Han-text scan for targeted strings.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- If ja/ru still show English, fix ja/ru source ARB values and regenerate. Do not edit generated Dart.
|
||||
- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.
|
||||
- If generator or analyzer hits local cache permission friction, rerun serially before treating it as a code issue.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: pre-commit-quality-review
|
||||
description: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.
|
||||
---
|
||||
|
||||
# Pre-commit Quality Review
|
||||
|
||||
Check that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,
|
||||
so later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill
|
||||
grants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is
|
||||
not consent.
|
||||
|
||||
## Triage
|
||||
|
||||
Most commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a
|
||||
repository-wide scan.
|
||||
|
||||
- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the
|
||||
normal commit flow without extra confirmation. Say the review was skipped, never that it passed.
|
||||
- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions
|
||||
change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws
|
||||
responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.
|
||||
- An explicit structural review request bypasses triage.
|
||||
|
||||
The snapshot under review is the intended commit compared with its baseline:
|
||||
|
||||
| Operation | Baseline |
|
||||
|---|---|
|
||||
| Regular commit | `HEAD` |
|
||||
| Amend | Parent of the commit being replaced |
|
||||
| Squash | Version preceding the agreed range |
|
||||
| Initial commit | Empty tree |
|
||||
|
||||
Ask when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of
|
||||
partially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial
|
||||
staging.
|
||||
|
||||
## Review the Final Structure
|
||||
|
||||
Scope the review to the responsibility boundaries this change touched. Read the complete final implementation of the
|
||||
affected units and compare before and after, not only the last patch. Report issues this change introduced or
|
||||
worsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a
|
||||
concrete concern needs, and disclose missing essential context rather than claiming a complete pass.
|
||||
|
||||
Focus on:
|
||||
|
||||
- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled
|
||||
with implementation detail; responsibilities in layers that should not own them.
|
||||
- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate
|
||||
nothing; similar names or interfaces hiding different responsibilities.
|
||||
- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually
|
||||
dependent helpers; names and decomposition that do not reduce reading effort.
|
||||
- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,
|
||||
scattered ownership of error handling, cleanup, or state updates.
|
||||
- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite
|
||||
invalidated; coupling that makes existing test boundaries hard to use.
|
||||
|
||||
Not findings: length, a missing design pattern, naming taste, or anything without code evidence and a real
|
||||
maintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,
|
||||
or demand abstractions for speculative needs.
|
||||
|
||||
Business logic, UI behavior, performance, security, platform compatibility, and full functional review are out of
|
||||
scope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or
|
||||
widening this review.
|
||||
|
||||
## Findings and the User's Decision
|
||||
|
||||
A finding qualifies only when all three hold:
|
||||
|
||||
- Before-and-after code and the necessary call relationships show this change introduced or worsened it.
|
||||
- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one
|
||||
responsibility. Clarity or elegance alone is not enough.
|
||||
- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.
|
||||
|
||||
With no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the
|
||||
existing commit authorization. Do not ask for confirmation because a review happened, and do not collect optional
|
||||
improvements into a suggestion list.
|
||||
|
||||
With qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,
|
||||
and give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the
|
||||
smallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to
|
||||
optimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the
|
||||
user recognizes their configured workflow, for example: "The structural issues above support consolidating the
|
||||
duplicate state-update paths. Should I make that change before committing?"
|
||||
|
||||
Existing explicit authorization for these findings is enough to act without asking again. The user may accept all,
|
||||
some, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were
|
||||
restaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.
|
||||
|
||||
## Resolve and Finish
|
||||
|
||||
Once authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task
|
||||
scope; explain separately if either must change. Verify only the accepted findings and the direct effects of the
|
||||
adjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold
|
||||
and exceeds the existing authorization.
|
||||
|
||||
Before committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and
|
||||
review only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,
|
||||
`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse
|
||||
results that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot
|
||||
as verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the
|
||||
commit.
|
||||
|
||||
Report the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.
|
||||
Claim the commit is done only after it exists.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: provider-tests
|
||||
description: Use when adding or updating FlClash Riverpod provider tests, notifier tests, or state-management tests in this repository.
|
||||
---
|
||||
|
||||
# Provider Tests
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for tests under `test/providers/` or any change that validates Riverpod providers, generated notifiers, app state defaults, or provider interactions.
|
||||
|
||||
For broader test expansion, pair this with `.agents/rules.md` and `.agents/commands.md`.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the provider under test and its generated public API before writing assertions.
|
||||
2. Use `ProviderContainer` directly when no widget tree is needed.
|
||||
3. Dispose containers in teardown or with `addTearDown(container.dispose)`.
|
||||
4. Prefer generated notifier APIs over implementation details. Generated `update()` takes a callback:
|
||||
|
||||
```dart
|
||||
notifier.update((state) => newValue);
|
||||
```
|
||||
|
||||
5. Mock external dependencies with `mocktail`; register fallback values for freezed params used with `any()`.
|
||||
6. Keep tests focused on behavior: defaults, state transitions, persistence boundaries, and side effects.
|
||||
7. Run the narrowest relevant test first:
|
||||
|
||||
```bash
|
||||
flutter test test/providers/
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Do not use `dart test`; FlClash models and provider tests may depend on Flutter types.
|
||||
- Re-check source defaults before asserting them; provider defaults can drift.
|
||||
- If async provider timing matters, wait on provider futures or state changes instead of fixed sleeps.
|
||||
@@ -1,47 +1,108 @@
|
||||
---
|
||||
name: ui-work
|
||||
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, navigation surfaces, async feedback, or user-facing interactions.
|
||||
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, icons or glyphs, shapes and corner radii, navigation surfaces, async feedback, or user-facing interactions.
|
||||
---
|
||||
|
||||
# UI Work
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows, dialogs, and interaction behavior.
|
||||
Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows,
|
||||
dialogs, and interaction behavior.
|
||||
|
||||
## Workflow
|
||||
## Conventions
|
||||
|
||||
1. Locate existing nearby widgets and reuse their patterns before adding new abstractions.
|
||||
2. Follow current Material You and Surfboard-like visual conventions.
|
||||
3. Use existing providers, notifiers, and helpers where possible.
|
||||
4. Keep `child:` last in widget constructors.
|
||||
5. Prefer `const` constructors and final locals.
|
||||
6. Localize user-facing text through ARB; use `localization` when text changes are non-trivial.
|
||||
7. Add focused widget tests when behavior changes, especially for rendering states, taps, scrolling, and empty/error states.
|
||||
8. For asynchronous controls, define separately:
|
||||
- authoritative provider/domain state;
|
||||
- display-only state such as a minimum progress duration;
|
||||
- tap policy while work or display holds are active;
|
||||
- failure/disposal cleanup, normally in `finally` for animations and timers.
|
||||
9. Run targeted verification:
|
||||
- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the
|
||||
Material You, Surfboard-like look the rest of the app has. Do not introduce a visual system for one screen or rewrite
|
||||
a layout the request does not need.
|
||||
- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,
|
||||
and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not
|
||||
await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).
|
||||
- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows
|
||||
section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.
|
||||
- Every icon lives in `lib/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it
|
||||
with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled
|
||||
button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the
|
||||
fill animation, and when to fill.
|
||||
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
|
||||
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
|
||||
keep scrims from stacking.
|
||||
- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)
|
||||
rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in
|
||||
`.agents/architecture.md` has the reasons.
|
||||
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
|
||||
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
|
||||
- User-facing text goes through ARB; use `localization` when text changes are non-trivial.
|
||||
- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a
|
||||
minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in
|
||||
`finally` for animations and timers. `CoreStatusButton` and the proxies delay test action are the reference implementations,
|
||||
described under User-Facing Core And Delay Feedback in `.agents/architecture.md`.
|
||||
|
||||
```bash
|
||||
flutter analyze
|
||||
flutter test test/widgets/
|
||||
```
|
||||
## Corner Radii
|
||||
|
||||
All corner radii come from `lib/common/shape.dart`: a token, a derived value, or a named off-scale value passed to its
|
||||
builders. Never write a bare radius literal at a call site.
|
||||
|
||||
The scale is picked by the component's **shortest side**. 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 one 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: buttons, tracks, indicators, progress, avatars |
|
||||
|
||||
- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`
|
||||
builders; `AppShape` mirrors it as `RoundedSuperellipseBorder`, `full` included, plus `circle`, `input`, and the
|
||||
`all`/`top`/`vertical`/`of` builders. Use `AppShape.full` for pills, never `StadiumBorder`; the Corner Radius
|
||||
section of `.agents/rules.md` explains why and lists the superellipse API per layer.
|
||||
- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever
|
||||
the size comes from a `LayoutBuilder` or scales with text.
|
||||
- `ThemeData.withAppShapes` in `lib/common/shape.dart` already applies the scale to card, dialog, bottom sheet, snack
|
||||
bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon
|
||||
button themes. Do not restate those shapes at call sites, and
|
||||
leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
|
||||
|
||||
`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its
|
||||
corner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`
|
||||
(`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy
|
||||
group header in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the
|
||||
one-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the
|
||||
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card at `ProxyCardType.min` 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 all three heights and the smallest dashboard tile.
|
||||
|
||||
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts
|
||||
the floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8
|
||||
up. `AppInputBorder` clips the notch region and paints the full superellipse through it, as Flutter's own
|
||||
`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.
|
||||
|
||||
Nested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute
|
||||
the outer value, as `_cardRadius` in `lib/widgets/popup.dart`, `thumbRadius + _kThumbInset` in `lib/widgets/tab.dart`, and the
|
||||
selection ring in `lib/widgets/palette.dart` do. Adding a token for one of these is how a scale grows without bound.
|
||||
|
||||
A component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass
|
||||
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/widgets/sidebar.dart` does with 10, between the
|
||||
chip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.
|
||||
|
||||
## Verification
|
||||
|
||||
- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,
|
||||
including the exception path of any async action. Run `flutter analyze` and `flutter test test/widgets/` plus the
|
||||
tests of the touched view.
|
||||
- The scans in `test/lint/` fail on the common UI mistakes and run as part of `flutter test`: an `IconButton` whose
|
||||
icon is an icon needs a `tooltip`; a field that constructs its own controller, notifier, or focus node is released in
|
||||
the same file; a public declaration nothing references is dead. `.agents/rules.md` explains each rule and its
|
||||
allowlist.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Do not introduce a new visual system for one screen.
|
||||
- Do not manually edit generated localization or provider files.
|
||||
- Avoid broad layout rewrites unless the requested change requires them.
|
||||
- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors
|
||||
bypass them immediately.
|
||||
- Do not leave loading animations active when callbacks throw. Test the exception path, not only the successful tap.
|
||||
|
||||
## Current Interaction Examples
|
||||
|
||||
- `CoreStatusButton` watches `coreStatusProvider` but keeps its 600-millisecond connecting hold locally. Taps are ignored
|
||||
during the hold or genuine connecting state; disconnected cancels the hold immediately.
|
||||
- Proxy delay testing writes `0` while pending, the measured delay on success, and `-1` on failure. `DelayTestButton` resets
|
||||
its animation in `finally`.
|
||||
- Do not leave loading animations active when callbacks throw.
|
||||
- Do not manually edit generated localization or provider files.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/core-platform
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/localization
|
||||
@@ -0,0 +1 @@
|
||||
../../.agents/skills/pre-commit-quality-review
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/ui-work
|
||||
@@ -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.
|
||||
"""
|
||||
+8
-5
@@ -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"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# freezed emits lines of trailing spaces, and generated code is never edited by hand.
|
||||
lib/**/generated/** -whitespace
|
||||
@@ -23,9 +23,11 @@ cask "flclash" do
|
||||
|
||||
app "FlClash.app"
|
||||
|
||||
postflight do
|
||||
system_command "xattr",
|
||||
args: ["-rd", "com.apple.quarantine", "#{appdir}/FlClash.app"]
|
||||
postflight_steps do
|
||||
run "/usr/bin/xattr",
|
||||
args: ["-rd", "com.apple.quarantine", "{{appdir}}/FlClash.app"],
|
||||
writable_paths: ["FlClash.app"],
|
||||
writable_base: :appdir
|
||||
end
|
||||
|
||||
uninstall quit: "com.follow.clash"
|
||||
|
||||
@@ -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"
|
||||
+300
-103
@@ -2,25 +2,47 @@ name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
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.4'
|
||||
GO_VERSION: '1.26.4'
|
||||
NDK_VERSION: r28c
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
dart:
|
||||
name: Dart
|
||||
# A private copy pays for its Actions minutes, so it runs the checks only when
|
||||
# started by hand.
|
||||
if: &checks github.event_name == 'workflow_dispatch' || !github.event.repository.private
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Test release notes generation
|
||||
shell: bash
|
||||
run: bash .github/scripts/generate_release_notes_test.sh
|
||||
- name: Test commit message hook
|
||||
run: bash tool/check_commit_msg_test.sh
|
||||
|
||||
- name: Test comment density hook
|
||||
run: bash tool/check_comment_density_test.sh
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: plugins/rust_api/rust
|
||||
cache-directories: .dart_tool/hooks_runner
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
@@ -32,46 +54,131 @@ 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 the Go core build hook
|
||||
run: |
|
||||
yq -i '.hooks.user_defines.setup.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
|
||||
|
||||
plugins:
|
||||
name: Plugins
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- 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
|
||||
dart test
|
||||
|
||||
desktop:
|
||||
name: Desktop ${{ matrix.platform }}
|
||||
if: *checks
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux
|
||||
os: ubuntu-latest
|
||||
- platform: windows
|
||||
os: windows-2022
|
||||
- platform: macos
|
||||
os: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.4'
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: desktop-${{ matrix.platform }}
|
||||
workspaces: |
|
||||
services/helper
|
||||
plugins/rust_api/rust
|
||||
cache-directories: .dart_tool/hooks_runner
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libsecret-1-dev
|
||||
|
||||
- name: Setup Xcode
|
||||
if: matrix.platform == 'macos'
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Enable git long paths (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
run: git config --global core.longpaths true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
# Compiles the desktop runners and the native plugin code that only the
|
||||
# tagged release matrix built before; the bundle itself is discarded.
|
||||
- name: Build debug bundle
|
||||
run: flutter build ${{ matrix.platform }} --debug
|
||||
|
||||
go:
|
||||
name: Go core
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Validate Go core wrapper
|
||||
@@ -79,23 +186,126 @@ jobs:
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
go test .
|
||||
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 .
|
||||
GOOS=linux go vet ./platform
|
||||
|
||||
- name: Validate Rust components
|
||||
- name: Setup NDK
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
|
||||
- name: Validate the Android core library
|
||||
working-directory: core
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
|
||||
run: |
|
||||
# Nothing else compiles the `android && cgo` files before a `v*` tag.
|
||||
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; }
|
||||
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
|
||||
|
||||
android:
|
||||
name: Android unit tests
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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: Setup NDK
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Run Android unit tests
|
||||
working-directory: android
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
|
||||
run: |
|
||||
{
|
||||
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
|
||||
# `:core` only calls System.loadLibrary; the excluded Flutter tasks would run
|
||||
# the native build hooks for every debug ABI, which no JVM unit test consumes.
|
||||
./gradlew :common:testDebugUnitTest :service:testDebugUnitTest :app:testDebugUnitTest \
|
||||
-x :app:compileFlutterBuildDebug -x :app:copyFlutterAssetsDebug
|
||||
|
||||
rust:
|
||||
name: Rust
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
services/helper
|
||||
plugins/rust_api/rust
|
||||
|
||||
- name: Check format
|
||||
run: |
|
||||
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
cargo test --manifest-path services/helper/Cargo.toml
|
||||
cargo fmt --manifest-path plugins/rust_api/rust/Cargo.toml -- --check
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cargo test --manifest-path services/helper/Cargo.toml
|
||||
cargo test --manifest-path plugins/rust_api/rust/Cargo.toml
|
||||
|
||||
windows-helper-test:
|
||||
name: Windows helper test
|
||||
if: *checks
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: services/helper
|
||||
|
||||
- name: Check format
|
||||
run: cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
|
||||
@@ -103,8 +313,9 @@ jobs:
|
||||
run: cargo test --manifest-path services/helper/Cargo.toml --features windows-service
|
||||
|
||||
build:
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [ test, windows-helper-test ]
|
||||
# Releases publish where the release links point: the public repository.
|
||||
if: github.repository == 'chen08209/FlClash' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [ dart, plugins, go, android, rust, windows-helper-test ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -159,14 +370,26 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.4'
|
||||
cache-dependency-path: |
|
||||
core/go.sum
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.platform }}-${{ matrix.arch || 'all' }}
|
||||
workspaces: services/helper
|
||||
cache-directories: .dart_tool/hooks_runner
|
||||
|
||||
- name: Setup Gradle
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Enable git long paths (Windows)
|
||||
if: startsWith(matrix.os, 'windows')
|
||||
run: git config --global core.longpaths true
|
||||
|
||||
# Stable publishes no Windows or Linux arm64 archive; master makes the action
|
||||
# clone the SDK at the pinned tag and let it fetch the host's own Dart SDK.
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
@@ -183,8 +406,9 @@ jobs:
|
||||
- name: Setup NDK
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: build-ndk
|
||||
with:
|
||||
ndk-version: r28c
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
add-to-path: true
|
||||
link-to-sdk: true
|
||||
|
||||
@@ -193,7 +417,14 @@ jobs:
|
||||
flutter --version
|
||||
flutter pub get
|
||||
|
||||
# See .agents/flutter-upgrade.md before bumping FLUTTER_VERSION.
|
||||
- name: Disable Impeller on Intel macOS
|
||||
if: matrix.platform == 'macos' && matrix.arch == 'amd64'
|
||||
run: plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist
|
||||
|
||||
- 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 +434,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 +460,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 }}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
name: pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'site/**'
|
||||
- 'tool/build_site.sh'
|
||||
- 'tool/render_site.mjs'
|
||||
- '.github/workflows/pages.yaml'
|
||||
workflow_run:
|
||||
workflows:
|
||||
- build
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
# head_branch also names a branch push's branch, so only a tag at the built commit counts as a release.
|
||||
if: >-
|
||||
github.repository == 'chen08209/FlClash' &&
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v') &&
|
||||
!contains(github.event.workflow_run.head_branch, '-')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
stable: ${{ steps.lookup.outputs.stable }}
|
||||
steps:
|
||||
- name: Look up the tag
|
||||
id: lookup
|
||||
env:
|
||||
NAME: ${{ github.event.workflow_run.head_branch }}
|
||||
SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
REMOTE: ${{ github.server_url }}/${{ github.repository }}
|
||||
run: |
|
||||
if git ls-remote --tags "$REMOTE" "refs/tags/$NAME" "refs/tags/$NAME^{}" | grep -q "^$SHA"; then
|
||||
echo 'stable=true' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
deploy:
|
||||
needs: tag
|
||||
if: >-
|
||||
github.repository == 'chen08209/FlClash' &&
|
||||
!cancelled() &&
|
||||
(github.event_name != 'workflow_run' || needs.tag.outputs.stable == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Assemble site
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bash tool/build_site.sh _site
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: _site
|
||||
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
+2
-1
@@ -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]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="main.dart (safe mode)" type="FlutterRunConfigurationType" factoryName="Flutter">
|
||||
<option name="additionalArgs" value="--dart-define-from-file env.json --dart-define=SAFE_MODE=true" />
|
||||
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{
|
||||
"label": "Flutter App (With Env)",
|
||||
"adapter": "Dart",
|
||||
"type": "flutter",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"cwd": "$ZED_WORKTREE_ROOT",
|
||||
"toolArgs": ["--dart-define-from-file", "env.json"],
|
||||
},
|
||||
{
|
||||
"label": "Flutter App (Safe Mode)",
|
||||
"adapter": "Dart",
|
||||
"type": "flutter",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"cwd": "$ZED_WORKTREE_ROOT",
|
||||
"toolArgs": [
|
||||
"--dart-define-from-file",
|
||||
"env.json",
|
||||
"--dart-define=SAFE_MODE=true",
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
[
|
||||
{
|
||||
"label": "flutter run (With Env)",
|
||||
"command": "flutter",
|
||||
"args": ["run", "--dart-define-from-file", "env.json"],
|
||||
"cwd": "$ZED_WORKTREE_ROOT",
|
||||
"use_new_terminal": true,
|
||||
},
|
||||
{
|
||||
"label": "flutter run (Safe Mode)",
|
||||
"command": "flutter",
|
||||
"args": [
|
||||
"run",
|
||||
"--dart-define-from-file",
|
||||
"env.json",
|
||||
"--dart-define=SAFE_MODE=true",
|
||||
],
|
||||
"cwd": "$ZED_WORKTREE_ROOT",
|
||||
"use_new_terminal": true,
|
||||
},
|
||||
]
|
||||
@@ -17,6 +17,9 @@ 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/flutter-upgrade.md](.agents/flutter-upgrade.md): SDK-pinned workarounds to recheck before changing the
|
||||
Flutter version.
|
||||
- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.
|
||||
|
||||
## Highest Priority Rules
|
||||
@@ -24,12 +27,21 @@ 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.
|
||||
- The request sets the scope. A pre-existing bug, cleanup, or refactor the task did not ask for is a follow-up in the
|
||||
summary, not part of the change. Edit a file surgically when a rewrite would give the same result, keep scratch checks
|
||||
out of the repository, and commit tests only where the task asks for them or the neighbouring code already keeps them.
|
||||
- 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 at or under 3.6%, 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.
|
||||
- Start FlClash on the host only as a safe mode build, and end only processes you started, by recorded pid. Never
|
||||
kill a FlClash instance by name; the user's own instance is not yours to close. See
|
||||
[.agents/rules.md](.agents/rules.md).
|
||||
- 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,12 +51,20 @@ 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.
|
||||
- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/common/shape.dart`,
|
||||
never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or
|
||||
`InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of
|
||||
[.agents/rules.md](.agents/rules.md).
|
||||
- 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.
|
||||
- Before reporting a change done, run `flutter pub get`, `flutter analyze --no-fatal-infos`, and the `flutter test`
|
||||
suites that cover it, as CI does. If a toolchain the build hook needs is missing (Go, and on Linux and Windows
|
||||
cargo), say which check was skipped rather than dropping it silently.
|
||||
|
||||
## Repo Skills
|
||||
|
||||
Use repo skills from `.agents/skills/` when a task matches their descriptions. Current skills cover localization,
|
||||
provider tests, UI work, and core/platform changes.
|
||||
Use repo skills from `.agents/skills/` when a task matches their descriptions; `.claude/skills/` symlinks expose the
|
||||
same skills to Claude Code. Current skills cover localization, UI work, core/platform changes, and pre-commit
|
||||
structural quality review.
|
||||
|
||||
+150
-413
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
# 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.
|
||||
@@ -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
|
||||
|
||||
@@ -1,139 +1,137 @@
|
||||
<div>
|
||||
<div align="center">
|
||||
|
||||
[**简体中文**](README_zh_CN.md)
|
||||
<img src="assets/images/icon.png" alt="FlClash" width="88">
|
||||
|
||||
# FlClash
|
||||
|
||||
A multi-platform proxy client based on ClashMeta. Simple to use, open source and ad-free.
|
||||
|
||||
**English** · [简体中文](README_zh_CN.md)
|
||||
|
||||
[](https://github.com/chen08209/FlClash/releases/latest)
|
||||
[](https://github.com/chen08209/FlClash/releases)
|
||||
[](LICENSE)
|
||||
[](https://t.me/FlClash)
|
||||
|
||||
[Website](https://chen08209.github.io/FlClash/) · [Download](#download) · [Changelog](CHANGELOG.md) · [Build from source](#build-from-source)
|
||||
|
||||
</div>
|
||||
|
||||
## FlClash
|
||||
|
||||
[](https://github.com/chen08209/FlClash/releases/)[](https://github.com/chen08209/FlClash/releases/)[](LICENSE)
|
||||
|
||||
[](https://t.me/FlClash)
|
||||
|
||||
A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
|
||||
|
||||
on Desktop:
|
||||
<p style="text-align: center;">
|
||||
<img alt="desktop" src="snapshots/desktop.gif">
|
||||
</p>
|
||||
|
||||
on Mobile:
|
||||
<p style="text-align: center;">
|
||||
<img alt="mobile" src="snapshots/mobile.gif">
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="snapshots/preview-dark.png">
|
||||
<img alt="FlClash dashboard on a MacBook and a phone" src="snapshots/preview.png" width="92%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
✈️ Multi-platform: Android, Windows, macOS and Linux
|
||||
|
||||
💻 Adaptive multiple screen sizes, Multiple color themes available
|
||||
|
||||
💡 Based on Material You Design, [Surfboard](https://github.com/getsurfboard/surfboard)-like UI
|
||||
|
||||
☁️ Supports data sync via WebDAV
|
||||
|
||||
✨ Support subscription link, Dark mode
|
||||
|
||||
## Use
|
||||
|
||||
### Linux
|
||||
|
||||
⚠️ Make sure to install the following dependencies before using them
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
sudo apt-get install libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
Support the following actions
|
||||
|
||||
```bash
|
||||
com.follow.clash.action.START
|
||||
|
||||
com.follow.clash.action.STOP
|
||||
|
||||
com.follow.clash.action.TOGGLE
|
||||
```
|
||||
- **One app for Android, Windows, macOS and Linux**, with x64 and ARM64 builds on the desktop.
|
||||
- **mihomo (Clash.Meta) core** with rule routing, proxy groups, latency tests, system proxy and TUN mode.
|
||||
- **Profiles** from a subscription link or a file, with a built-in editor, override scripts, and custom rules,
|
||||
proxies and proxy groups.
|
||||
- **Live views** of connections, requests, DNS queries and logs.
|
||||
- **Material You design** with dynamic color, light and dark themes, and layouts that adapt from phones to desktops.
|
||||
- **Backup and restore** through WebDAV or a local file.
|
||||
- **Platform touches**: a Quick Settings tile, per-app proxy and Android TV support on Android; a tray menu and
|
||||
global hotkeys on the desktop.
|
||||
- **Open source and ad-free**, licensed under GPL-3.0.
|
||||
|
||||
## Download
|
||||
|
||||
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
|
||||
Get the latest build from [GitHub Releases](https://github.com/chen08209/FlClash/releases/latest), or open the
|
||||
[website](https://chen08209.github.io/FlClash/#download), which picks the right file for your device.
|
||||
|
||||
### Homebrew
|
||||
| Platform | Packages | Notes |
|
||||
| --- | --- | --- |
|
||||
| Android | APK for `arm64-v8a`, `armeabi-v7a` and `x86_64` | Most phones use `arm64-v8a`. Also on the F-Droid repository below. |
|
||||
| Windows 10 and later | Installer (`.exe`) or portable `.zip`, for x64 and ARM64 | Pick ARM64 on Snapdragon and other ARM laptops. |
|
||||
| macOS 12 and later | DMG for Apple Silicon and Intel | Also on Homebrew. |
|
||||
| Linux | `.deb`, `.rpm` and AppImage, for x64 and ARM64 | See the tray note below. |
|
||||
|
||||
<p>
|
||||
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" height="56"></a>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/latest"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" height="56"></a>
|
||||
</p>
|
||||
|
||||
**Homebrew**
|
||||
|
||||
```bash
|
||||
brew tap chen08209/tap
|
||||
brew install --cask flclash
|
||||
```
|
||||
|
||||
## Build
|
||||
**Linux tray icon**
|
||||
|
||||
1. Update submodules
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
The `.deb` package installs its own dependencies. With the AppImage or the `.rpm`, install the AyatanaAppIndicator
|
||||
library so the tray icon can show:
|
||||
|
||||
2. Install `Flutter` and `Golang` environment
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-1 # Debian and Ubuntu
|
||||
sudo dnf install libayatana-appindicator-gtk3 # Fedora
|
||||
```
|
||||
|
||||
3. Build Application
|
||||
## Usage
|
||||
|
||||
- android
|
||||
**Import a profile from a link.** Opening a link in this form imports the subscription into FlClash. The `clashmeta://`
|
||||
and `flclash://` schemes work the same way.
|
||||
|
||||
1. Install `Android SDK`, `Android NDK`
|
||||
```text
|
||||
clash://install-config?url=<URL-encoded subscription link>
|
||||
```
|
||||
|
||||
2. Set `ANDROID_NDK` environment variable
|
||||
**Automate on Android.** Tasker, MacroDroid and similar apps can start, stop or toggle the proxy by starting an
|
||||
activity with one of these actions:
|
||||
|
||||
3. Run build script
|
||||
```text
|
||||
com.follow.clash.action.START
|
||||
com.follow.clash.action.STOP
|
||||
com.follow.clash.action.TOGGLE
|
||||
```
|
||||
|
||||
```bash
|
||||
dart setup.dart android
|
||||
```
|
||||
From a computer, the same works over adb:
|
||||
|
||||
- windows
|
||||
```bash
|
||||
adb shell am start -a com.follow.clash.action.TOGGLE
|
||||
```
|
||||
|
||||
1. Requires a Windows client
|
||||
## Build from source
|
||||
|
||||
2. Install `GCC`, `Inno Setup`
|
||||
You need [Flutter](https://docs.flutter.dev/get-started/install) 3.47 (release builds use 3.47.4),
|
||||
[Go](https://go.dev/dl/) 1.26 and [Rust](https://rustup.rs/) installed through rustup. Each platform builds on its own
|
||||
host, except Android, which builds anywhere.
|
||||
|
||||
3. Run build script
|
||||
```bash
|
||||
git clone --recursive https://github.com/chen08209/FlClash.git
|
||||
cd FlClash
|
||||
flutter pub get
|
||||
dart setup.dart android # or windows, macos, linux
|
||||
```
|
||||
|
||||
```bash
|
||||
dart setup.dart windows
|
||||
```
|
||||
Packages land in `dist/`. The Go core and the Rust libraries are compiled as part of the Flutter build.
|
||||
|
||||
- linux
|
||||
| Platform | Also needed |
|
||||
| --- | --- |
|
||||
| Android | Android SDK with the NDK. Add `--arch arm64` to build a single ABI. |
|
||||
| Windows | GCC (MinGW-w64) for the core and [Inno Setup](https://jrsoftware.org/isinfo.php) 6 for the installer. |
|
||||
| macOS | Xcode and Node.js. The script installs `appdmg` through npm. |
|
||||
| Linux | Debian or Ubuntu. The script installs the build packages with apt and downloads `appimagetool`. |
|
||||
|
||||
1. Requires a Linux client
|
||||
Run `dart setup.dart --help` for the remaining options, such as `--targets` to build only some package formats.
|
||||
|
||||
2. Dependencies are auto-installed by setup script, or manually:
|
||||
```bash
|
||||
sudo apt-get install -y libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
```
|
||||
## Support
|
||||
|
||||
3. Run build script
|
||||
Starring the repository is the easiest way to support the project. Questions and announcements go to the
|
||||
[Telegram channel](https://t.me/FlClash); bugs and feature requests go to
|
||||
[GitHub Issues](https://github.com/chen08209/FlClash/issues).
|
||||
|
||||
```bash
|
||||
dart setup.dart linux
|
||||
```
|
||||
<a href="https://star-history.com/#chen08209/FlClash&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date&theme=dark">
|
||||
<img alt="Star history" src="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date" width="640">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
- macOS
|
||||
## License
|
||||
|
||||
1. Requires a macOS client
|
||||
|
||||
2. Run build script
|
||||
|
||||
```bash
|
||||
dart setup.dart macos
|
||||
```
|
||||
|
||||
## Star
|
||||
|
||||
The easiest way to support developers is to click on the star (⭐) at the top of the page.
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
|
||||
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
|
||||
</a>
|
||||
</p>
|
||||
FlClash is released under the [GPL-3.0 license](LICENSE).
|
||||
|
||||
+96
-105
@@ -1,139 +1,130 @@
|
||||
<div>
|
||||
<div align="center">
|
||||
|
||||
[**English**](README.md)
|
||||
<img src="assets/images/icon.png" alt="FlClash" width="88">
|
||||
|
||||
# FlClash
|
||||
|
||||
基于 ClashMeta 的多平台代理客户端,简单易用,开源无广告。
|
||||
|
||||
[English](README.md) · **简体中文**
|
||||
|
||||
[](https://github.com/chen08209/FlClash/releases/latest)
|
||||
[](https://github.com/chen08209/FlClash/releases)
|
||||
[](LICENSE)
|
||||
[](https://t.me/FlClash)
|
||||
|
||||
[官网](https://chen08209.github.io/FlClash/zh/) · [下载](#下载) · [更新日志](CHANGELOG.md) · [从源码构建](#从源码构建)
|
||||
|
||||
</div>
|
||||
|
||||
## FlClash
|
||||
|
||||
[](https://github.com/chen08209/FlClash/releases/)[](https://github.com/chen08209/FlClash/releases/)[](LICENSE)
|
||||
|
||||
[](https://t.me/FlClash)
|
||||
|
||||
基于ClashMeta的多平台代理客户端,简单易用,开源无广告。
|
||||
|
||||
on Desktop:
|
||||
<p style="text-align: center;">
|
||||
<img alt="desktop" src="snapshots/desktop.gif">
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="snapshots/preview-dark.png">
|
||||
<img alt="FlClash 在 MacBook 与手机上的仪表盘" src="snapshots/preview.png" width="92%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
on Mobile:
|
||||
<p style="text-align: center;">
|
||||
<img alt="mobile" src="snapshots/mobile.gif">
|
||||
## 功能
|
||||
|
||||
- **一个应用覆盖 Android、Windows、macOS 和 Linux**,桌面端同时提供 x64 与 ARM64 版本。
|
||||
- **mihomo(Clash.Meta)内核**:规则分流、代理组、延迟测试、系统代理与 TUN 模式。
|
||||
- **配置管理**:通过订阅链接或文件导入配置,内置编辑器,支持覆写脚本以及自定义规则、代理和代理组。
|
||||
- **实时查看**连接、请求、DNS 查询和日志。
|
||||
- **Material You 设计**:支持动态取色、浅色与深色主题,布局随屏幕从手机到桌面自适应。
|
||||
- **备份与恢复**:通过 WebDAV 或本地文件。
|
||||
- **平台细节**:Android 上有快捷设置磁贴、分应用代理和 Android TV 支持;桌面端有托盘菜单和全局快捷键。
|
||||
- **开源无广告**,以 GPL-3.0 协议发布。
|
||||
|
||||
## 下载
|
||||
|
||||
从 [GitHub Releases](https://github.com/chen08209/FlClash/releases/latest) 获取最新版本,或打开
|
||||
[官网](https://chen08209.github.io/FlClash/zh/#download),它会自动为你的设备选好安装包。
|
||||
|
||||
| 平台 | 安装包 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Android | `arm64-v8a`、`armeabi-v7a`、`x86_64` 三种 APK | 绝大多数手机选 `arm64-v8a`。也可以通过下方的 F-Droid 仓库安装。 |
|
||||
| Windows 10 及以上 | 安装版(`.exe`)或便携版(`.zip`),分 x64 与 ARM64 | 骁龙等 ARM 架构笔记本选 ARM64。 |
|
||||
| macOS 12 及以上 | Apple Silicon 与 Intel 两种 DMG | 也可以通过 Homebrew 安装。 |
|
||||
| Linux | `.deb`、`.rpm` 和 AppImage,分 x64 与 ARM64 | 托盘依赖见下方说明。 |
|
||||
|
||||
<p>
|
||||
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" height="56"></a>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/latest"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" height="56"></a>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
✈️ 多平台: Android, Windows, macOS and Linux
|
||||
|
||||
💻 自适应多个屏幕尺寸,多种颜色主题可供选择
|
||||
|
||||
💡 基本 Material You 设计, 类[Surfboard](https://github.com/getsurfboard/surfboard)用户界面
|
||||
|
||||
☁️ 支持通过WebDAV同步数据
|
||||
|
||||
✨ 支持一键导入订阅, 深色模式
|
||||
|
||||
## Use
|
||||
|
||||
### Linux
|
||||
|
||||
⚠️ 使用前请确保安装以下依赖
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
sudo apt-get install libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
支持下列操作
|
||||
|
||||
```bash
|
||||
com.follow.clash.action.START
|
||||
|
||||
com.follow.clash.action.STOP
|
||||
|
||||
com.follow.clash.action.TOGGLE
|
||||
```
|
||||
|
||||
## Download
|
||||
|
||||
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
|
||||
|
||||
### Homebrew
|
||||
**Homebrew**
|
||||
|
||||
```bash
|
||||
brew tap chen08209/tap
|
||||
brew install --cask flclash
|
||||
```
|
||||
|
||||
## Build
|
||||
**Linux 托盘图标**
|
||||
|
||||
1. 更新 submodules
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
`.deb` 安装包会自动安装所需依赖。使用 AppImage 或 `.rpm` 时,需要先安装 AyatanaAppIndicator 库,托盘图标才能显示:
|
||||
|
||||
2. 安装 `Flutter` 以及 `Golang` 环境
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-1 # Debian 与 Ubuntu
|
||||
sudo dnf install libayatana-appindicator-gtk3 # Fedora
|
||||
```
|
||||
|
||||
3. 构建应用
|
||||
## 使用
|
||||
|
||||
- android
|
||||
**通过链接导入配置。** 打开下面格式的链接,即可把订阅导入 FlClash。`clashmeta://` 和 `flclash://` 开头的链接同样可用。
|
||||
|
||||
1. 安装 `Android SDK` , `Android NDK`
|
||||
```text
|
||||
clash://install-config?url=<经过 URL 编码的订阅链接>
|
||||
```
|
||||
|
||||
2. 设置 `ANDROID_NDK` 环境变量
|
||||
**Android 自动化。** Tasker、MacroDroid 等应用可以用下面的 Action 启动 Activity,从而启动、停止或切换代理:
|
||||
|
||||
3. 运行构建脚本
|
||||
```text
|
||||
com.follow.clash.action.START
|
||||
com.follow.clash.action.STOP
|
||||
com.follow.clash.action.TOGGLE
|
||||
```
|
||||
|
||||
```bash
|
||||
dart setup.dart android
|
||||
```
|
||||
在电脑上也可以通过 adb 触发:
|
||||
|
||||
- windows
|
||||
```bash
|
||||
adb shell am start -a com.follow.clash.action.TOGGLE
|
||||
```
|
||||
|
||||
1. 你需要一个windows客户端
|
||||
## 从源码构建
|
||||
|
||||
2. 安装 `GCC`,`Inno Setup`
|
||||
需要 [Flutter](https://docs.flutter.dev/get-started/install) 3.47(正式版构建使用 3.47.4)、[Go](https://go.dev/dl/) 1.26,
|
||||
以及通过 rustup 安装的 [Rust](https://rustup.rs/)。桌面端需要在对应系统上构建,Android 在任意系统上都能构建。
|
||||
|
||||
3. 运行构建脚本
|
||||
```bash
|
||||
git clone --recursive https://github.com/chen08209/FlClash.git
|
||||
cd FlClash
|
||||
flutter pub get
|
||||
dart setup.dart android # 或 windows、macos、linux
|
||||
```
|
||||
|
||||
```bash
|
||||
dart setup.dart windows
|
||||
```
|
||||
安装包输出到 `dist/`。Go 内核和 Rust 库会在 Flutter 构建过程中一并编译。
|
||||
|
||||
- linux
|
||||
| 平台 | 额外依赖 |
|
||||
| --- | --- |
|
||||
| Android | 带 NDK 的 Android SDK。加上 `--arch arm64` 可以只构建单个 ABI。 |
|
||||
| Windows | 编译内核用的 GCC(MinGW-w64),以及打包安装程序用的 [Inno Setup](https://jrsoftware.org/isinfo.php) 6。 |
|
||||
| macOS | Xcode 与 Node.js。脚本会通过 npm 安装 `appdmg`。 |
|
||||
| Linux | Debian 或 Ubuntu。脚本会用 apt 安装构建所需的软件包,并下载 `appimagetool`。 |
|
||||
|
||||
1. 你需要一个linux客户端
|
||||
其余选项可以运行 `dart setup.dart --help` 查看,例如用 `--targets` 只构建部分安装包格式。
|
||||
|
||||
2. 依赖会由 setup 脚本自动安装,也可以手动安装:
|
||||
```bash
|
||||
sudo apt-get install -y libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
```
|
||||
## 支持
|
||||
|
||||
3. 运行构建脚本
|
||||
给仓库点一个 Star 是支持项目最简单的方式。问题讨论和更新通知请关注 [Telegram 频道](https://t.me/FlClash),
|
||||
Bug 与功能建议请提交到 [GitHub Issues](https://github.com/chen08209/FlClash/issues)。
|
||||
|
||||
```bash
|
||||
dart setup.dart linux
|
||||
```
|
||||
<a href="https://star-history.com/#chen08209/FlClash&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date&theme=dark">
|
||||
<img alt="Star history" src="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date" width="640">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
- macOS
|
||||
## 许可证
|
||||
|
||||
1. 你需要一个macOS客户端
|
||||
|
||||
2. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart setup.dart macos
|
||||
```
|
||||
|
||||
## Star
|
||||
|
||||
支持开发者的最简单方式是点击页面顶部的星标(⭐)。
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
|
||||
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
|
||||
</a>
|
||||
</p>
|
||||
FlClash 以 [GPL-3.0 协议](LICENSE)发布。
|
||||
|
||||
+8
-35
@@ -1,43 +1,16 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
include: lint_options.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- lib/l10n/intl/**
|
||||
- lib/**/generated/**
|
||||
- plugins/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
errors:
|
||||
invalid_annotation_target: ignore
|
||||
|
||||
linter:
|
||||
rules:
|
||||
# Style
|
||||
prefer_single_quotes: true
|
||||
require_trailing_commas: true
|
||||
sort_child_properties_last: true
|
||||
|
||||
# Avoid unnecessary code
|
||||
unnecessary_new: true
|
||||
unnecessary_this: true
|
||||
unnecessary_const: true
|
||||
avoid_print: true
|
||||
avoid_unnecessary_containers: true
|
||||
sized_box_for_whitespace: true
|
||||
|
||||
# Prefer best practices
|
||||
prefer_const_constructors: true
|
||||
prefer_const_declarations: true
|
||||
prefer_final_locals: true
|
||||
prefer_final_in_for_each: true
|
||||
prefer_is_empty: true
|
||||
prefer_is_not_empty: true
|
||||
prefer_interpolation_to_compose_strings: true
|
||||
|
||||
# Type safety
|
||||
always_declare_return_types: true
|
||||
annotate_overrides: true
|
||||
use_key_in_widget_constructors: true
|
||||
no_leading_underscores_for_local_identifiers: true
|
||||
|
||||
# Async
|
||||
use_build_context_synchronously: true
|
||||
unnecessary_await_in_return: true
|
||||
@@ -1,3 +1,4 @@
|
||||
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.util.Properties
|
||||
|
||||
@@ -79,8 +80,17 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
|
||||
configure<CrashlyticsExtension> {
|
||||
nativeSymbolUploadEnabled = hasReleaseSigning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
// Unit tests live under android/tests/ instead of each module's src/test.
|
||||
getByName("test").java.setSrcDirs(listOf("../tests/app"))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -93,6 +103,13 @@ flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
// The Crashlytics plugin finalizes R8 with the mapping upload but leaves the native symbol upload to the caller.
|
||||
if (hasReleaseSigning) {
|
||||
tasks.matching { it.name == "assembleRelease" || it.name == "bundleRelease" }.configureEach {
|
||||
finalizedBy("uploadCrashlyticsSymbolFileRelease")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":service"))
|
||||
implementation(project(":common"))
|
||||
@@ -105,4 +122,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,9 +23,11 @@
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
|
||||
<application
|
||||
android:name=".FlClashApplication"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,9 @@ object ServiceController {
|
||||
binding = null
|
||||
}
|
||||
|
||||
fun invokeMethod(data: String, callback: (String) -> Unit): Result<Unit> = runCatching {
|
||||
fun invokeMethod(data: String, callback: (ByteArray) -> Unit): Result<Unit> = runCatching {
|
||||
Core.invokeMethod(data) { result ->
|
||||
callback(result.orEmpty())
|
||||
callback(result ?: ByteArray(0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,151 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
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 isLocalNetworkPermissionGranted(): 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 requestLocalNetworkPermission(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 isLocalNetworkPermissionGranted(): Boolean =
|
||||
sdkInt < Build.VERSION_CODES.CINNAMON_BUN ||
|
||||
ContextCompat.checkSelfPermission(
|
||||
GlobalState.application,
|
||||
Manifest.permission.ACCESS_LOCAL_NETWORK,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
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 requestLocalNetworkPermission(callback: (Boolean) -> Unit) =
|
||||
plugin.requestLocalNetworkPermission(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,363 @@
|
||||
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 { shouldStart ->
|
||||
if (shouldStart) {
|
||||
app.requestLocalNetworkPermission { launchRequest(true) }
|
||||
} else {
|
||||
launchRequest(false)
|
||||
}
|
||||
}
|
||||
} 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(withLocalNetworkFallback(options))
|
||||
if (startedAtMillis == 0L) {
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
fail(request)
|
||||
return@transition false
|
||||
}
|
||||
if (!isCurrent(request)) {
|
||||
return@transition false
|
||||
}
|
||||
mutableRunState.value = RunState.STARTED
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// system/mixed hand TCP to the kernel via the tun subnet, which Android 17 gates as local network.
|
||||
private fun withLocalNetworkFallback(options: VpnOptions): VpnOptions {
|
||||
if (!options.enable || options.stack !in KERNEL_TCP_STACKS ||
|
||||
host.isLocalNetworkPermissionGranted()
|
||||
) {
|
||||
return options
|
||||
}
|
||||
host.showToast(sharedState.localNetworkTip)
|
||||
return options.copy(stack = FALLBACK_STACK)
|
||||
}
|
||||
|
||||
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 {
|
||||
val KERNEL_TCP_STACKS = setOf("system", "mixed")
|
||||
const val FALLBACK_STACK = "gvisor"
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
@@ -6,10 +6,13 @@ import com.google.gson.annotations.SerializedName
|
||||
data class SharedState(
|
||||
val startTip: String = "Starting VPN...",
|
||||
val stopTip: String = "Stopping VPN...",
|
||||
val localNetworkTip: String =
|
||||
"Local network permission denied: using the gvisor stack, LAN is unreachable.",
|
||||
val crashlytics: Boolean = true,
|
||||
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,16 +50,32 @@ 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 val requestLocalNetworkCallback = PendingCallback<Boolean>()
|
||||
|
||||
private var isRequestingNotificationPermission = false
|
||||
|
||||
private var isRequestingLocalNetworkPermission = false
|
||||
|
||||
private var skipLocalNetworkPermissionRequest = false
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
private val packageResolver by lazy {
|
||||
@@ -61,9 +85,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 +148,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 +186,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 +200,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,20 +272,20 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun updateExcludeFromRecents(value: Boolean?) {
|
||||
val taskId = activity?.taskId ?: return
|
||||
val am = getSystemService(GlobalState.application, ActivityManager::class.java)
|
||||
val task = am?.appTasks?.firstOrNull {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
it.taskInfo.taskId == activity?.taskId
|
||||
it.taskInfo?.taskId == taskId
|
||||
} else {
|
||||
it.taskInfo.id == activity?.taskId
|
||||
it.taskInfo?.id == taskId
|
||||
}
|
||||
}
|
||||
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 +293,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 +306,82 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
} ?: invokeRequestNotificationCallback(true)
|
||||
return
|
||||
return@onMainThread
|
||||
}
|
||||
invokeRequestNotificationCallback(true)
|
||||
}
|
||||
|
||||
private fun invokeRequestNotificationCallback(shouldStart: Boolean) {
|
||||
isRequestingNotificationPermission = false
|
||||
requestNotificationCallback?.invoke(shouldStart)
|
||||
requestNotificationCallback = null
|
||||
requestNotificationCallback.resolve(shouldStart)
|
||||
}
|
||||
|
||||
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) {
|
||||
invokeVpnPrepareCallback(false)
|
||||
vpnPrepareCallback = callback
|
||||
fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestLocalNetworkCallback.replace(callback, supersededValue = false)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN || hasLocalNetworkPermission()) {
|
||||
invokeRequestLocalNetworkCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
if (skipLocalNetworkPermissionRequest) {
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
return@onMainThread
|
||||
}
|
||||
if (isRequestingLocalNetworkPermission) {
|
||||
return@onMainThread
|
||||
}
|
||||
val activity = activity
|
||||
if (activity == null) {
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
return@onMainThread
|
||||
}
|
||||
isRequestingLocalNetworkPermission = true
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(Manifest.permission.ACCESS_LOCAL_NETWORK),
|
||||
LOCAL_NETWORK_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasLocalNetworkPermission(): Boolean = ContextCompat.checkSelfPermission(
|
||||
GlobalState.application,
|
||||
Manifest.permission.ACCESS_LOCAL_NETWORK,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun invokeRequestLocalNetworkCallback(granted: Boolean) {
|
||||
isRequestingLocalNetworkPermission = false
|
||||
requestLocalNetworkCallback.resolve(granted)
|
||||
}
|
||||
|
||||
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 +392,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 +412,29 @@ 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)
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
@@ -292,13 +442,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 +467,11 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
channel.invokeMethod("exit", null)
|
||||
activity = null
|
||||
detachFromActivity()
|
||||
invokeVpnPrepareCallback(false)
|
||||
invokeRequestNotificationCallback(false)
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
}
|
||||
|
||||
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
@@ -324,17 +486,37 @@ 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
|
||||
}
|
||||
|
||||
LOCAL_NETWORK_PERMISSION_REQUEST_CODE -> {
|
||||
skipLocalNetworkPermissionRequest = true
|
||||
invokeRequestLocalNetworkCallback(
|
||||
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
|
||||
const val LOCAL_NETWORK_PERMISSION_REQUEST_CODE = 1004
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -63,12 +67,13 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
result.error("INVALID_ARGUMENT", "Method call payload must be a string", null)
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
ServiceController.invokeMethod(data) { response ->
|
||||
result.success(response)
|
||||
}.onFailure { error ->
|
||||
result.error("CORE_ERROR", error.message, null)
|
||||
}
|
||||
// Inline rather than a coroutine per call, which could swap two: watchRoute
|
||||
// keeps the latest intent by the order calls reach the Core
|
||||
// (dispatchMethodCall in core/method.go), and this only hands data over.
|
||||
ServiceController.invokeMethod(data) { response ->
|
||||
result.success(response)
|
||||
}.onFailure { error ->
|
||||
result.error("CORE_ERROR", error.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -59,19 +59,24 @@ val Intent.toPendingIntent: PendingIntent
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
fun Service.startForeground(notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
var channel = manager?.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL)
|
||||
if (channel == null) {
|
||||
channel = NotificationChannel(
|
||||
GlobalState.NOTIFICATION_CHANNEL,
|
||||
getString(R.string.service_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
manager?.createNotificationChannel(channel)
|
||||
}
|
||||
fun Context.ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL) != null) {
|
||||
return
|
||||
}
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
GlobalState.NOTIFICATION_CHANNEL,
|
||||
getString(R.string.service_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun Service.startForeground(notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
GlobalState.NOTIFICATION_ID,
|
||||
|
||||
@@ -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 {
|
||||
@@ -20,6 +33,13 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// :app strips what it packages; Crashlytics symbolication needs the unstripped libraries to reach its merge.
|
||||
packaging {
|
||||
jniLibs {
|
||||
keepDebugSymbols += "**/*.so"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
@@ -36,3 +56,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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,30 +9,20 @@ message("CMAKE_BUILD_TYPE ${CMAKE_BUILD_TYPE}")
|
||||
|
||||
if (NOT "${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
add_compile_options(-O3 -flto -g0 -fno-exceptions -fno-rtti)
|
||||
add_link_options(-flto -Wl,--gc-sections,--strip-all)
|
||||
add_compile_options(-O3 -flto -g -fno-exceptions -fno-rtti)
|
||||
add_link_options(-flto -Wl,--gc-sections)
|
||||
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"
|
||||
@@ -66,12 +68,6 @@ Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||
return new_string(traffic);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
|
||||
suspend(suspended);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
|
||||
@@ -82,7 +78,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 +92,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 +150,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);
|
||||
const auto value = new_bytes(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 +178,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");
|
||||
"([B)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,44 +10,115 @@ 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;
|
||||
}
|
||||
|
||||
jbyteArray jni_new_bytes(JNIEnv *env, const char *str) {
|
||||
const auto length = str == nullptr ? 0 : static_cast<int>(strlen(str));
|
||||
const auto array = env->NewByteArray(length);
|
||||
if (jni_clear_exception(env) || array == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
|
||||
if (jni_clear_exception(env)) {
|
||||
env->DeleteLocalRef(array);
|
||||
return nullptr;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
void jni_attach_thread(scoped_jni *jni) {
|
||||
if (global_vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ extern void initialize_jni(JavaVM *vm, JNIEnv *env);
|
||||
|
||||
extern jstring jni_new_string(JNIEnv *env, const char *str);
|
||||
|
||||
extern jbyteArray jni_new_bytes(JNIEnv *env, const char *str);
|
||||
|
||||
extern char *jni_get_string(JNIEnv *env, jstring str);
|
||||
|
||||
extern void jni_attach_thread(scoped_jni *jni);
|
||||
@@ -19,6 +21,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); \
|
||||
@@ -32,3 +36,4 @@ extern void release_string(char **value);
|
||||
#define del_global(obj) env->DeleteGlobalRef(obj)
|
||||
#define get_string(jstr) jni_get_string(env, jstr)
|
||||
#define new_string(cstr) jni_new_string(env, cstr)
|
||||
#define new_bytes(cstr) jni_new_bytes(env, cstr)
|
||||
|
||||
@@ -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,
|
||||
@@ -61,10 +60,6 @@ object Core {
|
||||
)
|
||||
}
|
||||
|
||||
external fun suspended(
|
||||
suspended: Boolean,
|
||||
)
|
||||
|
||||
private external fun invokeMethod(
|
||||
data: String,
|
||||
cb: InvokeInterface,
|
||||
@@ -72,12 +67,12 @@ object Core {
|
||||
|
||||
fun invokeMethod(
|
||||
data: String,
|
||||
cb: (result: String?) -> Unit,
|
||||
cb: (result: ByteArray?) -> Unit,
|
||||
) {
|
||||
invokeMethod(
|
||||
data,
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
override fun onResult(result: ByteArray?) {
|
||||
cb(result)
|
||||
}
|
||||
},
|
||||
@@ -94,8 +89,8 @@ object Core {
|
||||
} else {
|
||||
setEventListener(
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
callback(result)
|
||||
override fun onResult(result: ByteArray?) {
|
||||
callback(result?.decodeToString())
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -111,8 +106,8 @@ object Core {
|
||||
initParamsString,
|
||||
setupParamsString,
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
callback(result)
|
||||
override fun onResult(result: ByteArray?) {
|
||||
callback(result?.decodeToString())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,5 +4,5 @@ import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
interface InvokeInterface {
|
||||
fun onResult(result: String?)
|
||||
fun onResult(result: ByteArray?)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
[versions]
|
||||
firebaseBom = "34.15.0"
|
||||
minSdk = "23"
|
||||
targetSdk = "36"
|
||||
compileSdk = "36"
|
||||
firebaseBom = "34.19.0"
|
||||
minSdk = "24"
|
||||
targetSdk = "37"
|
||||
compileSdk = "37"
|
||||
ndkVersion = "28.2.13676358"
|
||||
coreKtx = "1.17.0"
|
||||
annotationJvm = "1.9.1"
|
||||
coreSplashscreen = "1.0.1"
|
||||
gson = "2.13.1"
|
||||
smaliDexlib2 = "3.0.9"
|
||||
coreKtx = "1.19.1"
|
||||
annotationJvm = "1.11.0"
|
||||
coreSplashscreen = "1.2.0"
|
||||
gson = "2.14.0"
|
||||
smaliDexlib2 = "3.0.10"
|
||||
junit = "4.13.2"
|
||||
kotlinxCoroutines = "1.11.0"
|
||||
|
||||
[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" }
|
||||
|
||||
+1
-1
@@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-all.zip
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+27
-5
@@ -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 {
|
||||
@@ -44,25 +47,42 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
networkInfos[network] = NetworkInfo()
|
||||
updateDns()
|
||||
scheduleUpdateDns()
|
||||
}
|
||||
|
||||
override fun onLosing(network: Network, maxMsToLive: Int) {
|
||||
networkInfos[network]?.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
|
||||
updateDns()
|
||||
val info = networkInfos[network] ?: return
|
||||
info.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
|
||||
scheduleUpdateDns()
|
||||
if (maxMsToLive > 0) {
|
||||
mainHandler.postDelayed({
|
||||
if (networkInfos.containsKey(network)) {
|
||||
updateDns()
|
||||
}
|
||||
}, maxMsToLive.toLong() + 50)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
networkInfos.remove(network)
|
||||
updateDns()
|
||||
scheduleUpdateDns()
|
||||
}
|
||||
|
||||
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
||||
networkInfos[network]?.dnsList = linkProperties.dnsServers
|
||||
updateDns()
|
||||
scheduleUpdateDns()
|
||||
}
|
||||
}
|
||||
|
||||
private val updateDnsTask = Runnable(::updateDns)
|
||||
|
||||
// A switch arrives as a burst, and a network that just came up has no DNS
|
||||
// until its link properties follow; the Core flushes its cache per list.
|
||||
private fun scheduleUpdateDns() {
|
||||
mainHandler.removeCallbacks(updateDnsTask)
|
||||
mainHandler.postDelayed(updateDnsTask, DNS_UPDATE_DELAY_MILLIS)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
updateDns()
|
||||
connectivity?.registerNetworkCallback(request, callback)
|
||||
@@ -104,6 +124,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
mainHandler.removeCallbacksAndMessages(null)
|
||||
try {
|
||||
connectivity?.unregisterNetworkCallback(callback)
|
||||
} finally {
|
||||
@@ -114,6 +135,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
}
|
||||
|
||||
private const val DNS_PORT = 53
|
||||
private const val DNS_UPDATE_DELAY_MILLIS = 200L
|
||||
|
||||
private fun InetAddress.asSocketAddressText(port: Int): String = when (this) {
|
||||
is Inet6Address -> "[$hostAddress]:$port"
|
||||
|
||||
+26
-22
@@ -11,6 +11,7 @@ import androidx.core.content.getSystemService
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.ensureNotificationChannel
|
||||
import com.follow.clash.common.quickIntent
|
||||
import com.follow.clash.common.receiveBroadcastFlow
|
||||
import com.follow.clash.common.startForeground
|
||||
@@ -22,9 +23,9 @@ import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.getSpeedTrafficText
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
@@ -33,6 +34,7 @@ import kotlinx.coroutines.launch
|
||||
private data class ExtendedNotificationParams(
|
||||
val title: String,
|
||||
val stopText: String,
|
||||
val showStopAction: Boolean,
|
||||
val contentText: String,
|
||||
)
|
||||
|
||||
@@ -40,6 +42,7 @@ private val NotificationParams.extended: ExtendedNotificationParams
|
||||
get() = ExtendedNotificationParams(
|
||||
title,
|
||||
stopText,
|
||||
showStopAction,
|
||||
Core.getSpeedTrafficText(onlyStatisticsProxy),
|
||||
)
|
||||
|
||||
@@ -48,31 +51,31 @@ internal class NotificationModule(
|
||||
private val scope: CoroutineScope,
|
||||
) : ServiceModule {
|
||||
override fun start() {
|
||||
service.ensureNotificationChannel()
|
||||
update(ServiceConfig.notificationParams.value.extended)
|
||||
scope.launch {
|
||||
val screenFlow = service.receiveBroadcastFlow {
|
||||
service.receiveBroadcastFlow {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}.map { intent ->
|
||||
intent.action == Intent.ACTION_SCREEN_ON
|
||||
}.onStart {
|
||||
emit(isScreenOn())
|
||||
}.distinctUntilChanged().collectLatest { screenOn ->
|
||||
if (!screenOn) return@collectLatest
|
||||
combine(
|
||||
flow {
|
||||
while (true) {
|
||||
delay(1_000)
|
||||
emit(Unit)
|
||||
}
|
||||
},
|
||||
ServiceConfig.notificationParams,
|
||||
) { _, params ->
|
||||
params.extended
|
||||
}.distinctUntilChanged()
|
||||
.collect(::update)
|
||||
}
|
||||
|
||||
combine(
|
||||
flow {
|
||||
while (true) {
|
||||
delay(1_000)
|
||||
emit(Unit)
|
||||
}
|
||||
},
|
||||
ServiceConfig.notificationParams,
|
||||
screenFlow,
|
||||
) { _, params, screenOn ->
|
||||
params.takeIf { screenOn }?.extended
|
||||
}.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.collect(::update)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,17 +103,18 @@ internal class NotificationModule(
|
||||
}
|
||||
}
|
||||
|
||||
private val stopIntent by lazy { QuickAction.STOP.quickIntent.toPendingIntent }
|
||||
|
||||
private fun update(params: ExtendedNotificationParams) {
|
||||
service.startForeground(
|
||||
with(notificationBuilder) {
|
||||
setContentTitle(params.title)
|
||||
setContentText(params.contentText)
|
||||
clearActions()
|
||||
addAction(
|
||||
0,
|
||||
params.stopText,
|
||||
QuickAction.STOP.quickIntent.toPendingIntent,
|
||||
).build()
|
||||
if (params.showStopAction) {
|
||||
addAction(0, params.stopText, stopIntent)
|
||||
}
|
||||
build()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ internal class ServiceModules(private val service: Service) {
|
||||
val nextModules = listOf(
|
||||
NotificationModule(service, nextScope),
|
||||
NetworkObserveModule(service),
|
||||
SuspendModule(service, nextScope),
|
||||
)
|
||||
val startedModules = mutableListOf<ServiceModule>()
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package com.follow.clash.service.modules
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.getSystemService
|
||||
import com.follow.clash.common.receiveBroadcastFlow
|
||||
import com.follow.clash.core.Core
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class SuspendModule(
|
||||
private val service: Service,
|
||||
private val scope: CoroutineScope,
|
||||
) : ServiceModule {
|
||||
private fun isScreenOn() =
|
||||
service.getSystemService<PowerManager>()?.isInteractive ?: true
|
||||
|
||||
private val isDeviceIdle: Boolean
|
||||
get() = service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
|
||||
|
||||
private fun updateSuspension(screenOn: Boolean) {
|
||||
Core.suspended(!screenOn && isDeviceIdle)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
scope.launch {
|
||||
val screenFlow = service.receiveBroadcastFlow {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
|
||||
}.map {
|
||||
isScreenOn()
|
||||
}.onStart {
|
||||
emit(isScreenOn())
|
||||
}
|
||||
|
||||
screenFlow.collect(::updateSuspension)
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Core.suspended(false)
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,12 @@
|
||||
android:viewportHeight="240"
|
||||
tools:ignore="VectorRaster">
|
||||
<path
|
||||
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
|
||||
android:pathData="M48.1,80.89L168.44,11.41A23.16,23.16 0,0 1,191.6,51.53L71.26,121.01A23.16,23.16 0,0 1,48.1,80.89Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
<path
|
||||
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
|
||||
android:pathData="M78.98,134.37L139.16,99.63A23.16,23.16 0,0 1,162.32,139.75L102.14,174.49A23.16,23.16 0,0 1,78.98,134.37Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
<path
|
||||
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
|
||||
android:pathData="M98.28,207.92A23.16,23.16 0,1 1,144.6,207.92A23.16,23.16 0,1 1,98.28,207.92Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
android:viewportHeight="240"
|
||||
tools:ignore="VectorRaster">
|
||||
<path
|
||||
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
|
||||
android:pathData="M48.1,80.89L168.44,11.41A23.16,23.16 0,0 1,191.6,51.53L71.26,121.01A23.16,23.16 0,0 1,48.1,80.89Z"
|
||||
android:fillColor="#6666FB"/>
|
||||
<path
|
||||
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
|
||||
android:pathData="M78.98,134.37L139.16,99.63A23.16,23.16 0,0 1,162.32,139.75L102.14,174.49A23.16,23.16 0,0 1,78.98,134.37Z"
|
||||
android:fillColor="#336AB6"/>
|
||||
<path
|
||||
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
|
||||
android:pathData="M98.28,207.92A23.16,23.16 0,1 1,144.6,207.92A23.16,23.16 0,1 1,98.28,207.92Z"
|
||||
android:fillColor="#5CA8E9"/>
|
||||
</vector>
|
||||
|
||||
@@ -19,10 +19,10 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||
id("com.android.application") version "9.4.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.20" apply false
|
||||
id("com.google.gms.google-services") version "4.5.0" apply false
|
||||
id("com.google.firebase.crashlytics") version "3.0.7" apply false
|
||||
id("com.google.firebase.crashlytics") version "3.0.8" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -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,721 @@
|
||||
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, stack: String = "gvisor") = 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 = stack,
|
||||
routeAddress = emptyList(),
|
||||
)
|
||||
|
||||
private fun configuredState(enable: Boolean = true, stack: String = "gvisor") = SharedState(
|
||||
vpnOptions = vpnOptions(enable, stack),
|
||||
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 localNetworkGranted: Boolean = true,
|
||||
private val vpnGranted: Boolean = true,
|
||||
private val holdVpnPreparation: Boolean = false,
|
||||
) : AppGateway {
|
||||
var beforeVpnPrepared: (() -> Unit)? = null
|
||||
var cancelledPreparations = 0
|
||||
var localNetworkRequests = 0
|
||||
|
||||
private var heldCallback: ((Boolean) -> Unit)? = null
|
||||
|
||||
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
|
||||
callback(notificationGranted)
|
||||
|
||||
override fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) {
|
||||
localNetworkRequests++
|
||||
callback(localNetworkGranted)
|
||||
}
|
||||
|
||||
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 localNetworkPermissionGranted = true
|
||||
var vpnServiceActive = true
|
||||
var tile: TileGateway? = null
|
||||
var app: AppGateway? = null
|
||||
var beforeStartService: (() -> Unit)? = null
|
||||
var lastStartOptions: VpnOptions? = 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 isLocalNetworkPermissionGranted(): Boolean = localNetworkPermissionGranted
|
||||
|
||||
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++
|
||||
lastStartOptions = options
|
||||
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)
|
||||
}
|
||||
|
||||
/** Android 17 drops the kernel hop system/mixed rely on until the permission is granted. */
|
||||
@Test
|
||||
fun `a denied local network permission falls back to the gvisor stack`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.localNetworkPermissionGranted = false
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState(stack = "system"))
|
||||
|
||||
assertTrue(machine.requestStart().await())
|
||||
assertEquals("gvisor", host.lastStartOptions?.stack)
|
||||
assertTrue(host.toasts.contains(configuredState().localNetworkTip))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a granted local network permission keeps the chosen stack`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState(stack = "mixed"))
|
||||
|
||||
assertTrue(machine.requestStart().await())
|
||||
assertEquals("mixed", host.lastStartOptions?.stack)
|
||||
assertFalse(host.toasts.contains(configuredState().localNetworkTip))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a proxy-only start keeps its stack without the local network permission`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
host.localNetworkPermissionGranted = false
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState(enable = false, stack = "system"))
|
||||
|
||||
assertTrue(machine.requestStart().await())
|
||||
assertEquals("system", host.lastStartOptions?.stack)
|
||||
assertFalse(host.toasts.contains(configuredState().localNetworkTip))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the local network prompt is asked once and never blocks the start`() = runTest {
|
||||
val host = FakeHost(backgroundScope)
|
||||
val app = FakeApp(localNetworkGranted = false)
|
||||
host.app = app
|
||||
host.localNetworkPermissionGranted = false
|
||||
val machine = ServiceStateMachine(host)
|
||||
machine.syncSharedState(configuredState(stack = "system"))
|
||||
|
||||
assertTrue(machine.requestStart().await())
|
||||
assertEquals(1, app.localNetworkRequests)
|
||||
assertEquals(1, host.startCalls)
|
||||
assertEquals("gvisor", host.lastStartOptions?.stack)
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+491
-267
File diff suppressed because it is too large
Load Diff
+513
-289
File diff suppressed because it is too large
Load Diff
+522
-298
File diff suppressed because it is too large
Load Diff
+327
-103
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"rule": "规则",
|
||||
"rules": "规则",
|
||||
"global": "全局",
|
||||
"direct": "直连",
|
||||
"dashboard": "仪表盘",
|
||||
@@ -8,16 +9,32 @@
|
||||
"profiles": "配置",
|
||||
"tools": "工具",
|
||||
"logs": "日志",
|
||||
"logsDesc": "日志捕获记录",
|
||||
"resources": "资源",
|
||||
"resourcesDesc": "外部资源相关信息",
|
||||
"trafficUsage": "流量统计",
|
||||
"networkSpeed": "网络速度",
|
||||
"outboundMode": "出站模式",
|
||||
"networkDetection": "网络检测",
|
||||
"serviceStatus": "服务状态",
|
||||
"serviceRestricted": "访问受限",
|
||||
"serviceAvailable": "可用",
|
||||
"serviceUnavailable": "不可用",
|
||||
"serviceFailed": "检测失败",
|
||||
"serviceDisallowedIsp": "不允许的 ISP",
|
||||
"serviceBlocked": "已被封禁",
|
||||
"serviceUnsupportedRegion": "地区不支持",
|
||||
"serviceOriginalsOnly": "仅限自制内容",
|
||||
"serviceComingSoon": "即将上线",
|
||||
"servicePending": "待检测",
|
||||
"serviceManage": "管理服务",
|
||||
"serviceCheck": "检测",
|
||||
"serviceCheckAll": "全部检测",
|
||||
"serviceCheckedAt": "检测于 {time}",
|
||||
"upload": "上传",
|
||||
"download": "下载",
|
||||
"nullProfileDesc": "没有配置文件,请先添加配置文件",
|
||||
"usedTraffic": "已用流量",
|
||||
"totalTraffic": "总流量",
|
||||
"expireTime": "到期时间",
|
||||
"nullProfileDesc": "添加一个配置文件后即可开始使用",
|
||||
"settings": "设置",
|
||||
"language": "语言",
|
||||
"defaultText": "默认",
|
||||
@@ -27,30 +44,27 @@
|
||||
"en": "英语",
|
||||
"ja": "日语",
|
||||
"ru": "俄语",
|
||||
"zh_CN": "中文简体",
|
||||
"zhCN": "中文简体",
|
||||
"theme": "主题",
|
||||
"themeDesc": "设置深色模式,调整色彩",
|
||||
"override": "覆写",
|
||||
"allowLan": "局域网代理",
|
||||
"allowLanDesc": "允许通过局域网访问代理",
|
||||
"tun": "虚拟网卡",
|
||||
"tunDesc": "仅在管理员模式生效",
|
||||
"minimizeOnExit": "退出时最小化",
|
||||
"minimizeOnExitDesc": "修改系统默认退出事件",
|
||||
"autoLaunch": "自启动",
|
||||
"autoLaunchDesc": "跟随系统自启动",
|
||||
"silentLaunch": "静默启动",
|
||||
"silentLaunchDesc": "后台启动",
|
||||
"silentLaunchDesc": "启动时不显示窗口",
|
||||
"autoRun": "自动运行",
|
||||
"autoRunDesc": "应用打开时自动运行",
|
||||
"logcat": "日志捕获",
|
||||
"logcatDesc": "禁用将会隐藏日志入口",
|
||||
"autoCheckUpdate": "自动检查更新",
|
||||
"autoCheckUpdateDesc": "应用启动时自动检查更新",
|
||||
"checkCertificate": "校验 TLS 证书",
|
||||
"checkCertificateDesc": "拒绝不受信任的证书。关闭后订阅和备份将暴露于中间人攻击",
|
||||
"accessControl": "访问控制",
|
||||
"accessControlDesc": "配置应用访问代理",
|
||||
"application": "应用程序",
|
||||
"applicationDesc": "修改应用程序相关设置",
|
||||
"edit": "编辑",
|
||||
"confirm": "确定",
|
||||
"update": "更新",
|
||||
@@ -80,7 +94,7 @@
|
||||
"dark": "深色",
|
||||
"importFromURL": "从URL导入",
|
||||
"submit": "提交",
|
||||
"doYouWantToPass": "是否要通过",
|
||||
"createProfileFromUrlTip": "是否要通过 {url} 创建配置?",
|
||||
"create": "创建",
|
||||
"pleaseUploadValidQrcode": "请上传有效的二维码",
|
||||
"blacklistMode": "黑名单模式",
|
||||
@@ -90,21 +104,26 @@
|
||||
"appAccessControl": "应用访问控制",
|
||||
"accessControlAllowDesc": "只允许选中应用进入VPN",
|
||||
"accessControlNotAllowDesc": "选中应用将会被排除在VPN之外",
|
||||
"accessControlDisabledDesc": "应用访问控制已关闭",
|
||||
"selected": "已选择",
|
||||
"proxyPort": "代理端口",
|
||||
"port": "端口",
|
||||
"logLevel": "日志等级",
|
||||
"show": "显示",
|
||||
"exit": "退出",
|
||||
"systemProxy": "系统代理",
|
||||
"safeMode": "安全模式",
|
||||
"safeModeAppTitle": "{appName}(安全模式)",
|
||||
"project": "项目",
|
||||
"core": "内核",
|
||||
"tabAnimation": "选项卡动画",
|
||||
"desc": "基于ClashMeta的多平台代理客户端,简单易用,开源无广告。",
|
||||
"startVpn": "正在启动VPN...",
|
||||
"stopVpn": "正在停止VPN...",
|
||||
"startVpn": "正在启动VPN…",
|
||||
"stopVpn": "正在停止VPN…",
|
||||
"localNetworkDeniedTip": "本地网络权限被拒绝:已改用 gvisor 栈,局域网无法访问。",
|
||||
"compatible": "兼容模式",
|
||||
"notSelectedTip": "当前代理组无法选中",
|
||||
"changeProxyFailedTip": "切换代理失败,已恢复上一次的选择",
|
||||
"databaseWriteFailedTip": "保存更改失败,已回滚",
|
||||
"tip": "提示",
|
||||
"account": "账号",
|
||||
"backup": "备份",
|
||||
@@ -118,6 +137,9 @@
|
||||
"addressHelp": "WebDAV服务器地址",
|
||||
"addressTip": "请输入有效的WebDAV地址",
|
||||
"password": "密码",
|
||||
"authentication": "认证",
|
||||
"authenticationDesc": "为本地代理端口启用认证,防止本机其他应用擅自使用",
|
||||
"authenticationSystemProxyDesc": "认证启用时不生效",
|
||||
"checkUpdate": "检查更新",
|
||||
"discoverNewVersion": "发现新版本",
|
||||
"checkUpdateError": "当前应用已经是最新版了",
|
||||
@@ -126,36 +148,36 @@
|
||||
"country": "区域",
|
||||
"search": "搜索",
|
||||
"allowBypass": "允许应用绕过VPN",
|
||||
"allowBypassDesc": "开启后部分应用可绕过VPN",
|
||||
"externalController": "外部控制器",
|
||||
"externalControllerDesc": "开启后将可以通过9090端口控制Clash内核",
|
||||
"ipv6Desc": "开启后将可以接收IPv6流量",
|
||||
"app": "应用",
|
||||
"general": "常规",
|
||||
"systemProxyDesc": "设置系统代理",
|
||||
"startupAndBackground": "启动与后台",
|
||||
"requestsAndUpdates": "请求与更新",
|
||||
"logsAndDiagnostics": "日志与诊断",
|
||||
"userAgent": "用户代理",
|
||||
"unifiedDelay": "统一延迟",
|
||||
"unifiedDelayDesc": "去除握手等额外延迟",
|
||||
"tcpConcurrent": "TCP并发",
|
||||
"tcpConcurrentDesc": "开启后允许TCP并发",
|
||||
"geodataLoader": "Geo低内存模式",
|
||||
"geodataLoaderDesc": "开启将使用Geo低内存加载器",
|
||||
"requests": "请求",
|
||||
"requestsDesc": "查看最近请求记录",
|
||||
"recentRequests": "最近请求",
|
||||
"findProcessMode": "查找进程",
|
||||
"init": "初始化",
|
||||
"infiniteTime": "长期有效",
|
||||
"connections": "连接",
|
||||
"connectionsDesc": "查看当前连接数据",
|
||||
"liveConnections": "实时连接",
|
||||
"inbound": "入站",
|
||||
"intranetIP": "内网 IP",
|
||||
"view": "查看",
|
||||
"cut": "剪切",
|
||||
"copy": "复制",
|
||||
"clipboardWriteFailed": "无法复制到剪贴板,所选内容可能过大",
|
||||
"paste": "粘贴",
|
||||
"testUrl": "测速链接",
|
||||
"sync": "同步",
|
||||
"exclude": "从最近任务中隐藏",
|
||||
"excludeDesc": "应用在后台时,从最近任务中隐藏应用",
|
||||
"excludeDesc": "应用在后台时,从最近任务中隐藏应用",
|
||||
"expand": "标准",
|
||||
"shrink": "紧凑",
|
||||
"min": "最小",
|
||||
@@ -170,17 +192,18 @@
|
||||
"go": "前往",
|
||||
"externalLink": "外部链接",
|
||||
"otherContributors": "其他贡献者",
|
||||
"appIconDesign": "应用图标设计",
|
||||
"autoCloseConnections": "自动关闭连接",
|
||||
"autoCloseConnectionsDesc": "切换节点后自动关闭连接",
|
||||
"onlyStatisticsProxy": "仅统计代理",
|
||||
"onlyStatisticsProxyDesc": "开启后,将只统计代理流量",
|
||||
"onlyStatisticsProxy": "仅统计代理流量",
|
||||
"showNotificationStopAction": "通知栏显示停止按钮",
|
||||
"pureBlackMode": "纯黑模式",
|
||||
"sidebarBlur": "侧边栏背景模糊",
|
||||
"sidebarBlurDesc": "侧边栏透出窗口后方模糊的桌面",
|
||||
"keepAliveIntervalDesc": "TCP保持活动间隔",
|
||||
"entries": "个条目",
|
||||
"local": "本地",
|
||||
"remote": "远程",
|
||||
"remoteBackupDesc": "备份数据到WebDAV",
|
||||
"localBackupDesc": "备份数据到本地",
|
||||
"mode": "模式",
|
||||
"time": "时间",
|
||||
"source": "来源",
|
||||
@@ -194,89 +217,156 @@
|
||||
"loose": "宽松",
|
||||
"profilesSort": "配置排序",
|
||||
"start": "启动",
|
||||
"runTime": "启动时间",
|
||||
"stop": "暂停",
|
||||
"dnsDesc": "更新DNS相关设置",
|
||||
"key": "键",
|
||||
"value": "值",
|
||||
"hostsDesc": "追加Hosts",
|
||||
"vpnTip": "重启VPN后改变生效",
|
||||
"vpnEnableDesc": "通过VpnService自动路由系统所有流量",
|
||||
"options": "选项",
|
||||
"loopback": "回环解锁工具",
|
||||
"loopbackDesc": "用于UWP回环解锁",
|
||||
"providers": "提供者",
|
||||
"enabled": "已开启",
|
||||
"disabled": "已关闭",
|
||||
"loopback": "UWP 回环解锁",
|
||||
"providers": "外部资源",
|
||||
"proxyProviders": "代理集",
|
||||
"ruleProviders": "规则集",
|
||||
"behavior": "行为",
|
||||
"format": "格式",
|
||||
"providerUrlTip": "仅支持远程资源",
|
||||
"subscriptionInfo": "订阅信息",
|
||||
"overrideDns": "覆写DNS",
|
||||
"overrideDnsDesc": "开启后将覆盖配置中的DNS选项",
|
||||
"overrideNtp": "覆写NTP",
|
||||
"ntpStatusDesc": "从NTP服务器获取时间,而非系统时钟",
|
||||
"server": "服务器",
|
||||
"ntpInterval": "同步间隔(分钟)",
|
||||
"dialerProxy": "拨号代理",
|
||||
"dialerProxyDesc": "用于连接NTP服务器的出站",
|
||||
"writeToSystem": "写入系统",
|
||||
"writeToSystemDesc": "同时设置系统时钟,Android上不生效",
|
||||
"overrideEntries": "覆写项",
|
||||
"addOverrideEntry": "添加覆写项",
|
||||
"discardChanges": "是否放弃更改?",
|
||||
"quickEdit": "快速编辑",
|
||||
"status": "状态",
|
||||
"statusDesc": "关闭后将使用系统DNS",
|
||||
"preferH3Desc": "优先使用DOH的http/3",
|
||||
"respectRules": "遵守规则",
|
||||
"respectRulesDesc": "DNS连接跟随rules,需配置proxy-server-nameserver",
|
||||
"respectRulesDesc": "DNS连接遵守规则,需配置Proxy Server Nameserver",
|
||||
"dnsMode": "DNS模式",
|
||||
"fakeipRange": "Fakeip范围",
|
||||
"fakeipFilter": "Fakeip过滤",
|
||||
"defaultNameserver": "默认域名服务器",
|
||||
"defaultNameserverDesc": "用于解析DNS服务器",
|
||||
"nameserver": "域名服务器",
|
||||
"nameserverDesc": "用于解析域名",
|
||||
"fakeipRange": "Fake-IP范围",
|
||||
"fakeipFilter": "Fake-IP过滤",
|
||||
"useHosts": "使用Hosts",
|
||||
"useSystemHosts": "使用系统Hosts",
|
||||
"nameserverPolicy": "域名服务器策略",
|
||||
"nameserverPolicyDesc": "指定对应域名服务器策略",
|
||||
"proxyNameserver": "代理域名服务器",
|
||||
"proxyNameserverDesc": "用于解析代理节点的域名",
|
||||
"fallback": "Fallback",
|
||||
"fallbackDesc": "一般情况下使用境外DNS",
|
||||
"fallbackFilter": "Fallback过滤",
|
||||
"geoipCode": "Geoip代码",
|
||||
"ipcidr": "IP/掩码",
|
||||
"domain": "域名",
|
||||
"listenRoutingMark": "监听路由标记",
|
||||
"listenRoutingMarkDesc": "仅Linux有效",
|
||||
"ipv6Timeout": "IPv6超时(毫秒)",
|
||||
"cacheAlgorithm": "缓存算法",
|
||||
"cacheMaxSize": "缓存大小",
|
||||
"fakeipRange6": "Fake-IP范围(IPv6)",
|
||||
"fakeipFilterMode": "Fake-IP过滤模式",
|
||||
"fakeipFilterModeDesc": "blacklist排除匹配项,whitelist仅用于匹配项,rule按规则匹配",
|
||||
"fakeipTtl": "Fake-IP TTL",
|
||||
"reset": "重置",
|
||||
"action_view": "显示/隐藏",
|
||||
"action_start": "启动/停止",
|
||||
"action_mode": "切换模式",
|
||||
"action_proxy": "系统代理",
|
||||
"action_tun": "虚拟网卡",
|
||||
"actionView": "显示/隐藏",
|
||||
"actionStart": "启动/停止",
|
||||
"actionMode": "切换模式",
|
||||
"actionProxy": "系统代理",
|
||||
"actionTun": "虚拟网卡",
|
||||
"actionRuleMode": "规则模式",
|
||||
"actionGlobalMode": "全局模式",
|
||||
"actionDirectMode": "直连模式",
|
||||
"actionDelayTest": "测试全部延迟",
|
||||
"actionUpdateProfiles": "更新全部配置",
|
||||
"disclaimer": "免责声明",
|
||||
"disclaimerDesc": "本软件仅供学习交流、科研等非商业性质的用途,严禁将本软件用于商业目的。如有任何商业行为,均与本软件无关。",
|
||||
"disclaimerDesc": "在使用 FlClash(以下简称“本软件”)前,请仔细阅读并充分理解本声明的全部内容。点击“同意”即表示你已阅读、理解并接受以下全部条款;如不同意,请点击“退出”并停止使用本软件。",
|
||||
"disclaimerSoftwareTitle": "软件性质",
|
||||
"disclaimerSoftwareContent": "本软件是基于 Clash.Meta(mihomo)内核的开源网络代理客户端,仅提供配置管理、规则分流与流量转发等本地工具功能。\n\n本软件自身不提供任何代理服务器、节点、订阅、机场或网络接入服务,也不与任何此类服务的提供者存在合作、代理或担保关系。",
|
||||
"disclaimerUsageTitle": "使用限制",
|
||||
"disclaimerUsageContent": "本软件仅供学习交流、技术研究等非商业用途。严禁将本软件用于任何商业目的,包括但不限于付费分发、捆绑销售、作为商业服务的组成部分或以本软件名义开展经营活动;任何商业行为均与本软件及其开发者无关。\n\n严禁将本软件用于违反你所在国家或地区法律法规的活动,包括但不限于绕过依法设置的网络访问限制、传播违法信息、实施网络攻击或侵犯他人合法权益。",
|
||||
"disclaimerResponsibilityTitle": "用户责任",
|
||||
"disclaimerResponsibilityContent": "你应自行确认使用本软件在你所在国家或地区是合法的,并对使用本软件的全部行为及其后果独立承担法律责任。\n\n你导入的订阅、节点与配置由你自行选择和判断,其来源是否合法、内容是否安全、服务是否稳定,均由你与相关提供者自行负责。",
|
||||
"disclaimerThirdPartyTitle": "第三方内容",
|
||||
"disclaimerThirdPartyContent": "订阅链接、配置文件、规则集、脚本、外部资源及外部链接均由第三方提供,开发者无法也不会对其合法性、准确性、安全性和可用性进行审查或担保。\n\n因使用第三方内容导致的数据泄露、财产损失、账号封禁或其他任何损失,由你与第三方自行解决,开发者不承担任何责任。",
|
||||
"disclaimerWarrantyTitle": "无担保声明",
|
||||
"disclaimerWarrantyContent": "本软件按“现状”和“可用”的原则提供,不附带任何明示或默示的担保,包括但不限于适销性、特定用途适用性、不侵权以及持续可用、无错误、无安全漏洞的担保。\n\n开发者不保证本软件能满足你的需求,也不保证本软件的运行不会中断或不会出现错误。",
|
||||
"disclaimerLiabilityTitle": "责任限制",
|
||||
"disclaimerLiabilityContent": "在适用法律允许的最大范围内,开发者及所有贡献者均不对因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊、惩罚性或后果性损害承担责任,包括但不限于数据丢失、设备损坏、网络异常、业务中断、利润损失或因此引起的任何法律纠纷,即使已被告知发生此类损害的可能性。",
|
||||
"disclaimerPrivacyTitle": "数据收集与隐私",
|
||||
"disclaimerPrivacyContent": "本软件不会收集或上传你的订阅地址、节点信息、配置内容、访问的网站、连接记录、流量内容和日志,这些数据仅保存在你的设备本地,开发者无法获取。\n\n本软件只会在你使用相应功能时访问外部网络,例如更新订阅时访问你提供的订阅地址、检查更新时访问 GitHub。\n\n桌面版(Windows、macOS、Linux)未集成任何数据统计或崩溃上报服务。Android 版集成了 Google Firebase 的以下两项服务,用于改进应用稳定性:",
|
||||
"disclaimerCrashlyticsTitle": "Firebase Crashlytics(崩溃分析)",
|
||||
"disclaimerCrashlyticsContent": "应用发生崩溃时,自动上传崩溃报告。\n\n收集内容:崩溃堆栈与错误信息、崩溃发生时间、应用版本与构建号、设备品牌与型号、Android 系统版本、屏幕方向、剩余内存与存储空间、设备是否已 root,以及一个随安装生成、重装后即重置的随机安装标识。\n\n用途:仅用于定位和修复崩溃问题。\n\n你可以在“工具 > 常规 > 崩溃分析”中随时关闭。",
|
||||
"disclaimerAnalyticsTitle": "Firebase Analytics(使用统计)",
|
||||
"disclaimerAnalyticsContent": "随 Firebase 自动收集基础的应用使用统计。\n\n收集内容:首次启动、应用打开与会话时长、应用更新等基础事件,应用实例标识,设备型号、系统版本、系统语言,以及由 IP 地址推断的国家或地区级大致位置。\n\n用途:仅用于了解活跃设备数、版本分布与系统兼容情况。开发者不会将这些数据用于广告,不会出售,也不会与你的订阅或配置相关联。",
|
||||
"disclaimerDataProcessingContent": "上述数据由 Google 代为处理和存储,可能被传输至你所在国家或地区以外(如美国)的服务器,并受 Google 隐私政策与 Firebase 隐私和安全说明约束。崩溃报告最多保留 90 天,统计数据按 Firebase 的默认策略保留。",
|
||||
"disclaimerLicenseTitle": "开源协议",
|
||||
"disclaimerLicenseContent": "本软件以 GPL-3.0 协议开源。你可以在遵守该协议的前提下自由使用、修改和分发本软件,但须同样以 GPL-3.0 协议开源并保留原作者的版权声明。\n\n本软件包含的第三方组件(包括 Clash.Meta 内核)分别遵循其各自的开源协议。基于本软件修改或分发的版本所产生的任何问题,与原作者无关。",
|
||||
"disclaimerChangesTitle": "声明变更",
|
||||
"disclaimerChangesContent": "开发者有权随版本更新修改本声明,修改后的内容随新版本发布即生效。你在更新后继续使用本软件,即视为接受修改后的声明。",
|
||||
"disclaimerAcceptTitle": "接受声明",
|
||||
"disclaimerAcceptContent": "一旦你安装、复制或使用本软件,即视为你已阅读并同意本声明的全部内容。如你不同意本声明的任何条款,请立即停止使用并卸载本软件。",
|
||||
"disclaimerAndroidOnly": "仅 Android",
|
||||
"disclaimerGooglePrivacy": "Google 隐私政策",
|
||||
"disclaimerFirebasePrivacy": "Firebase 隐私和安全说明",
|
||||
"agree": "同意",
|
||||
"hotkeyManagement": "快捷键管理",
|
||||
"hotkeyManagementDesc": "使用键盘控制应用程序",
|
||||
"pressKeyboard": "请按下按键",
|
||||
"inputCorrectHotkey": "请输入正确的快捷键",
|
||||
"hotkeyConflict": "快捷键冲突",
|
||||
"hotkeyDesc": "全局快捷键在窗口隐藏时依然生效,点击操作即可录制组合键。",
|
||||
"hotkeyNotSet": "未设置",
|
||||
"pressKeyboard": "请按下组合键",
|
||||
"hotkeyNeedsModifier": "至少包含 {modifiers} 中的一个",
|
||||
"hotkeyConflictWith": "已被“{action}”使用,保存后将移到这里",
|
||||
"hotkeyUnavailable": "未能注册,可能已被其他应用占用",
|
||||
"remove": "移除",
|
||||
"noHotKey": "暂无快捷键",
|
||||
"noNetwork": "无网络",
|
||||
"ipv6InboundDesc": "允许IPv6入站",
|
||||
"exportLogs": "导出日志",
|
||||
"exportSuccess": "导出成功",
|
||||
"iconStyle": "图标样式",
|
||||
"onlyIcon": "仅图标",
|
||||
"iconStyleFilled": "色块",
|
||||
"iconStylePlain": "纯图标",
|
||||
"iconStyleHidden": "隐藏",
|
||||
"filter": "筛选",
|
||||
"hideTimeoutProxies": "隐藏超时节点",
|
||||
"hideTimeoutProxiesDesc": "不显示上次延迟测试超时的节点",
|
||||
"stackMode": "栈模式",
|
||||
"network": "网络",
|
||||
"networkDesc": "修改网络相关设置",
|
||||
"bypassDomain": "排除域名",
|
||||
"bypassDomainDesc": "仅在系统代理启用时生效",
|
||||
"resetTip": "确定要重置吗?",
|
||||
"resetTip": "确定要重置吗?",
|
||||
"icon": "图片",
|
||||
"noData": "暂无数据",
|
||||
"fontFamily": "字体",
|
||||
"toggle": "切换",
|
||||
"system": "系统",
|
||||
"routeMode": "路由模式",
|
||||
"routeMode_bypassPrivate": "绕过私有路由地址",
|
||||
"routeMode_config": "使用配置",
|
||||
"routeModeBypassPrivate": "绕过私有路由地址",
|
||||
"routeModeConfig": "使用配置",
|
||||
"routeAddress": "路由地址",
|
||||
"routeAddressDesc": "配置监听路由地址",
|
||||
"pleaseInputAdminPassword": "请输入管理员密码",
|
||||
"interfaceNameMode": "出站网卡",
|
||||
"interfaceNameModeClear": "清空",
|
||||
"interfaceNameModeFollow": "跟随配置",
|
||||
"interfaceNameModeCustom": "自定义",
|
||||
"interfaceName": "网卡名称",
|
||||
"interfaceNameDesc": "出站连接使用的网卡名称",
|
||||
"copyEnvVar": "复制环境变量",
|
||||
"memoryInfo": "内存信息",
|
||||
"total": "总计",
|
||||
"releaseMemory": "释放内存",
|
||||
"memoryCoreHeapInuse": "堆内存使用中",
|
||||
"memoryCoreHeapIdle": "堆内存空闲",
|
||||
"memoryCoreStack": "协程栈",
|
||||
"memoryCoreRuntime": "运行时开销",
|
||||
"memoryCoreNotRunning": "内核未运行",
|
||||
"memoryAppResident": "常驻内存",
|
||||
"memoryAppShared": "应用及共享",
|
||||
"memoryEstimateDesc": "基于进程常驻内存估算,可能与系统显示的数值不同。",
|
||||
"memoryEstimateSharedDesc": "内核与应用运行在同一进程,内核部分按运行时统计估算,其余计入应用及共享内存。",
|
||||
"memoryReleased": "内存已释放",
|
||||
"memoryReleasedSize": "已释放 {size}",
|
||||
"releaseMemoryFailed": "释放内存失败",
|
||||
"cancel": "取消",
|
||||
"fileIsUpdate": "文件有修改,是否保存修改",
|
||||
"profileHasUpdate": "配置文件已经修改,是否关闭自动更新 ",
|
||||
"profileHasUpdate": "配置文件已经修改,是否关闭自动更新?",
|
||||
"hasCacheChange": "是否缓存修改",
|
||||
"copySuccess": "复制成功",
|
||||
"copyLink": "复制链接",
|
||||
@@ -286,22 +376,27 @@
|
||||
"listen": "监听",
|
||||
"undo": "撤销",
|
||||
"redo": "重做",
|
||||
"lineWrap": "自动换行",
|
||||
"none": "无",
|
||||
"basicConfig": "基本配置",
|
||||
"basicConfigDesc": "全局修改基本配置",
|
||||
"advancedConfig": "进阶配置",
|
||||
"advancedConfigDesc": "提供多样化配置",
|
||||
"advancedConfigDesc": "网络、DNS、附加规则与脚本",
|
||||
"selectedCountTitle": "已选择 {count} 项",
|
||||
"addRule": "添加规则",
|
||||
"ruleName": "规则名称",
|
||||
"content": "内容",
|
||||
"subRule": "子规则",
|
||||
"ruleTarget": "规则目标",
|
||||
"matchTarget": "MATCH-TARGET",
|
||||
"followProfile": "跟随配置",
|
||||
"sourceIp": "源IP",
|
||||
"noResolve": "不解析IP",
|
||||
"saveChanges": "是否保存更改?",
|
||||
"findProcessModeDesc": "开启后会有一定性能损耗",
|
||||
"tabAnimationDesc": "仅在移动视图中有效",
|
||||
"navigationBarStyle": "底栏",
|
||||
"floating": "悬浮",
|
||||
"docked": "固定",
|
||||
"slide": "滑动",
|
||||
"fade": "淡入",
|
||||
"pureBlack": "纯黑",
|
||||
"colorSchemes": "配色方案",
|
||||
"palette": "调色板",
|
||||
"tonalSpotScheme": "调性点缀",
|
||||
@@ -319,27 +414,38 @@
|
||||
"messageTestTip": "这是一条消息。",
|
||||
"crashTest": "崩溃测试",
|
||||
"crashDetected": "检测到崩溃",
|
||||
"crashDetectedTip": "检测到应用上次运行发生崩溃。为避免重复崩溃,已清除当前配置选择,并跳过本次自动配置。",
|
||||
"crashDetectedTip": "应用连续两次未能完成启动。为打断崩溃循环,已取消选中配置 {name},并跳过本次自动配置,你可以随时重新选中它。",
|
||||
"launchInterrupted": "启动未完成",
|
||||
"launchInterruptedTip": "应用上次在启动过程中意外退出。已跳过本次自动配置,你可以手动启动重试。",
|
||||
"clearData": "清除数据",
|
||||
"textScale": "文本缩放",
|
||||
"textScalePreview": "应用内的文字将以这个大小显示",
|
||||
"followSystem": "跟随系统",
|
||||
"internet": "互联网",
|
||||
"systemApp": "系统应用",
|
||||
"noNetworkApp": "无网络应用",
|
||||
"restoreStrategy": "恢复策略",
|
||||
"restoreStrategy_override": "覆盖",
|
||||
"restoreStrategy_compatible": "兼容",
|
||||
"restoreStrategyOverride": "覆盖",
|
||||
"restoreStrategyCompatible": "兼容",
|
||||
"logsTest": "日志测试",
|
||||
"emptyTip": "{label}不能为空",
|
||||
"urlTip": "{label}必须为URL",
|
||||
"numberTip": "{label}必须为数字",
|
||||
"interval": "间隔",
|
||||
"existsTip": "{label}当前已存在",
|
||||
"maxLengthTip": "{label}最多{max}个字符",
|
||||
"singleAdd": "单条添加",
|
||||
"batchAdd": "批量添加",
|
||||
"batchListInputTip": "每行一项,也可用逗号分隔",
|
||||
"batchMapInputTip": "每行一条,键和值之间用空格分隔",
|
||||
"batchPreviewTip": "将添加 {count} 项,跳过 {skipped} 项已存在",
|
||||
"lineIssueTip": "第 {line} 行:{message}",
|
||||
"singleValueTip": "{label}只能是一项",
|
||||
"deleteTip": "确定删除当前{label}吗?",
|
||||
"deleteMultipTip": "确定删除选中的{label}吗?",
|
||||
"nullTip": "暂无{label}",
|
||||
"script": "脚本",
|
||||
"color": "颜色",
|
||||
"rename": "重命名",
|
||||
"unnamed": "未命名",
|
||||
"pleaseEnterScriptName": "请输入脚本名称",
|
||||
"mixedPort": "混合端口",
|
||||
@@ -351,6 +457,7 @@
|
||||
"import": "导入",
|
||||
"importFile": "通过文件导入",
|
||||
"importUrl": "通过URL导入",
|
||||
"startFromScratch": "从零开始",
|
||||
"autoSetSystemDns": "自动设置系统DNS",
|
||||
"details": "{label}详情",
|
||||
"creationTime": "创建时间",
|
||||
@@ -369,7 +476,7 @@
|
||||
"request": "请求",
|
||||
"connected": "已连接",
|
||||
"disconnected": "已断开",
|
||||
"connecting": "连接中...",
|
||||
"connecting": "连接中…",
|
||||
"restartCoreTip": "您确定要重启核心吗?",
|
||||
"forceRestartCoreTip": "您确定要强制重启核心吗?",
|
||||
"dnsHijacking": "DNS劫持",
|
||||
@@ -379,21 +486,25 @@
|
||||
"crashlytics": "崩溃分析",
|
||||
"crashlyticsTip": "开启后,应用崩溃时自动上传不包含敏感信息的崩溃日志",
|
||||
"appendSystemDns": "追加系统DNS",
|
||||
"appendSystemDnsTip": "强制为配置附加系统DNS",
|
||||
"editRule": "编辑规则",
|
||||
"overrideMode": "覆写模式",
|
||||
"standardModeDesc": "标准模式,覆写基本配置,提供简单追加规则能力",
|
||||
"standardModeDesc": "标准模式,覆写基础配置,提供简单追加规则能力",
|
||||
"scriptModeDesc": "脚本模式,使用外部扩展脚本,提供一键覆写配置的能力",
|
||||
"addedRules": "附加规则",
|
||||
"controlGlobalAddedRules": "控制全局附加规则",
|
||||
"quickAdd": "快捷添加",
|
||||
"rulePresetBlockQuic": "屏蔽 QUIC",
|
||||
"rulePresetBlockStun": "屏蔽 STUN",
|
||||
"rulePresetBlockDot": "屏蔽 DNS over TLS",
|
||||
"rulePresetSystemServicesDirect": "Apple 与 Microsoft 直连",
|
||||
"rulePresetBittorrentDirect": "BT 下载直连",
|
||||
"rulePresetLanDirect": "局域网直连",
|
||||
"overrideScript": "覆写脚本",
|
||||
"goToConfigureScript": "前往配置脚本",
|
||||
"editGlobalRules": "编辑全局规则",
|
||||
"externalFetch": "外部获取",
|
||||
"confirmForceCrashCore": "确定要强制崩溃核心?",
|
||||
"confirmClearAllData": "确定要清除所有数据?",
|
||||
"loading": "加载中...",
|
||||
"loadTest": "加载测试",
|
||||
"loading": "加载中…",
|
||||
"yearsAgo": "{count} 年前",
|
||||
"monthsAgo": "{count} 个月前",
|
||||
"daysAgo": "{count} 天前",
|
||||
@@ -409,26 +520,33 @@
|
||||
"speedStatistics": "网速统计",
|
||||
"resetPageChangesTip": "当前页面存在更改,确定重置吗?",
|
||||
"overwriteTypeCustom": "自定义",
|
||||
"overwriteTypeCustomDesc": "自定义模式,支持完全自定义修改代理组以及规则",
|
||||
"overwriteTypeCustomDesc": "自定义模式,支持完全自定义修改代理、策略组以及规则",
|
||||
"unknownNetworkError": "未知网络错误",
|
||||
"restoreException": "恢复异常",
|
||||
"networkException": "网络异常,请检查连接后重试",
|
||||
"backupFromNewerVersion": "该备份来自更高版本的应用,请先更新应用再恢复",
|
||||
"networkTimeoutError": "请求超时,请检查网络或代理后重试",
|
||||
"networkHostLookupError": "无法解析服务器地址,请确认链接正确且 DNS 可用",
|
||||
"networkConnectionError": "无法连接到服务器,请检查网络连接或代理设置",
|
||||
"networkTlsError": "安全连接失败,服务器证书可能无效,或连接被拦截",
|
||||
"networkCancelledError": "请求已取消",
|
||||
"networkAccessDeniedError": "服务器拒绝访问(HTTP {code}),链接可能已过期或凭据有误",
|
||||
"networkNotFoundError": "该地址下没有内容(HTTP {code}),请确认链接是否正确",
|
||||
"networkRateLimitedError": "请求过于频繁(HTTP 429),请稍后再试",
|
||||
"networkServerError": "服务器出现问题(HTTP {code}),请稍后再试",
|
||||
"networkBadResponseError": "服务器拒绝了请求(HTTP {code})",
|
||||
"networkRequestFailed": "网络请求失败:{detail}",
|
||||
"invalidBackupFile": "无效备份文件",
|
||||
"pruneCache": "修剪缓存",
|
||||
"backupAndRestore": "备份与恢复",
|
||||
"backupAndRestoreDesc": "通过WebDAV或者文件同步数据",
|
||||
"restore": "恢复",
|
||||
"restoreSuccess": "恢复成功",
|
||||
"restoreFromWebDAVDesc": "通过WebDAV恢复数据",
|
||||
"restoreFromFileDesc": "通过文件恢复数据",
|
||||
"restoreOnlyConfig": "仅恢复配置文件",
|
||||
"restoreAllData": "恢复所有数据",
|
||||
"addProfile": "添加配置",
|
||||
"delayTest": "延迟测试",
|
||||
"proxyGroupEmpty": "策略组为空",
|
||||
"proxyGroupNameEmpty": "策略组名称不能为空",
|
||||
"proxyGroupNameDuplicate": "策略组名称重复",
|
||||
"confirmExitWindow": "确定要退出当前窗口吗?",
|
||||
"confirmExitWindow": "确定要退出当前窗口吗?",
|
||||
"dataChangedSave": "检测到数据有更改,是否保存",
|
||||
"selectProxyProviders": "选择代理集",
|
||||
"proxyFilter": "节点过滤器",
|
||||
@@ -438,9 +556,13 @@
|
||||
"excludeProxyFilter": "排除节点过滤器",
|
||||
"excludeType": "排除类型",
|
||||
"expectedStatus": "预期状态",
|
||||
"strategy": "策略",
|
||||
"tolerance": "容差",
|
||||
"selectProxies": "选择代理",
|
||||
"inputProxyGroupName": "输入策略组名称",
|
||||
"helperCorruptTip": "Helper 服务不可用,无法启用 TUN 模式,请重新安装 FlClash。",
|
||||
"coreBlockedByPolicyTip": "Windows 拒绝运行 FlClashCore.exe(错误 {code})。智能应用控制、AppLocker 等应用控制策略会拦截未签名程序,请在该策略中放行 FlClash 或关闭策略后重试。",
|
||||
"coreBlockedBySmartAppControlTip": "Windows 智能应用控制拦截了未签名的 FlClashCore.exe。请打开 Windows 安全中心 → 应用和浏览器控制 → 智能应用控制设置,选择「关闭」后重新启动 FlClash。智能应用控制关闭后无法再开启,除非重装 Windows。",
|
||||
"hideFromList": "从列表中隐藏",
|
||||
"testWhenUsed": "使用时测试",
|
||||
"disableUDP": "禁用UDP",
|
||||
@@ -460,7 +582,7 @@
|
||||
"basicStrategy": "基础策略",
|
||||
"editProxy": "编辑代理",
|
||||
"includeAllProxyProviders": "包含所有代理集",
|
||||
"includeAllProxyProvidersTip": "开启后将覆盖引入的代理集",
|
||||
"includeAllProxyProvidersTip": "开启后包含本配置的全部代理集:订阅自带的,以及任一策略组引用的配置和应用代理集",
|
||||
"addProxyProviders": "添加代理集",
|
||||
"includeAllProxies": "包含所有代理",
|
||||
"includeAllProxiesTip": "引入不包含策略组的所有代理,可在下方额外添加策略组",
|
||||
@@ -478,10 +600,11 @@
|
||||
"ruleActionDomainDesc": "匹配完整域名",
|
||||
"ruleActionDomainSuffixDesc": "匹配域名后缀",
|
||||
"ruleActionDomainKeywordDesc": "匹配域名关键字",
|
||||
"ruleActionDomainRegexDesc": "通配符匹配,仅支持*和?通配符",
|
||||
"ruleActionDomainRegexDesc": "使用域名正则表达式匹配",
|
||||
"ruleActionDomainWildcardDesc": "通配符匹配,仅支持*和?通配符",
|
||||
"ruleActionGeositeDesc": "匹配 Geosite 内的域名",
|
||||
"ruleActionIpCidrDesc": "匹配 IP 地址范围",
|
||||
"ruleActionIpCidr6Desc": "匹配 IP 地址范围, IP-CIDR6 只是一个别名",
|
||||
"ruleActionIpCidr6Desc": "匹配 IP 地址范围,IP-CIDR6 只是一个别名",
|
||||
"ruleActionIpSuffixDesc": "匹配 IP 后缀范围",
|
||||
"ruleActionIpAsnDesc": "匹配 IP 所属 ASN",
|
||||
"ruleActionGeoipDesc": "匹配 IP 所属国家代码",
|
||||
@@ -495,18 +618,21 @@
|
||||
"ruleActionInTypeDesc": "匹配入站类型",
|
||||
"ruleActionInUserDesc": "匹配入站用户名,支持使用 / 分隔多个用户名",
|
||||
"ruleActionInNameDesc": "匹配入站名称",
|
||||
"ruleActionRematchNameDesc": "匹配重匹配名称,多个名称用/分隔",
|
||||
"ruleActionProcessPathDesc": "使用完整进程路径匹配",
|
||||
"ruleActionProcessPathRegexDesc": "使用进程路径正则表达式匹配",
|
||||
"ruleActionProcessPathWildcardDesc": "使用进程路径通配符匹配,仅支持*和?通配符",
|
||||
"ruleActionProcessNameDesc": "使用进程匹配,在Android平台可以匹配包名",
|
||||
"ruleActionProcessNameRegexDesc": "使用进程名称正则表达式匹配,在Android平台可以匹配包名",
|
||||
"ruleActionProcessNameWildcardDesc": "使用进程名称通配符匹配,仅支持*和?通配符",
|
||||
"ruleActionUidDesc": "匹配 Linux USER ID",
|
||||
"ruleActionNetworkDesc": "匹配TCP或者UDP",
|
||||
"ruleActionDscpDesc": "匹配DSCP标记 (仅限 tproxy udp 入站)",
|
||||
"ruleActionDscpDesc": "匹配DSCP标记(仅限 tproxy udp 入站)",
|
||||
"ruleActionRuleSetDesc": "引用规则集合,需配置rule-providers",
|
||||
"ruleActionAndDesc": "逻辑规则 AND",
|
||||
"ruleActionOrDesc": "逻辑规则 OR",
|
||||
"ruleActionNotDesc": "逻辑规则 NOT",
|
||||
"ruleActionSubRuleDesc": "匹配至子规则,需要注意括号的使用",
|
||||
"ruleActionSubRuleDesc": "匹配至子规则,需要注意括号的使用",
|
||||
"ruleActionMatchDesc": "匹配所有请求,无需条件",
|
||||
"subRuleEmpty": "子规则为空",
|
||||
"proxyProvidersNotEmpty": "代理集不能为空",
|
||||
@@ -518,19 +644,19 @@
|
||||
"invalidSubRule": "{subRule} 是一个无效的SUB_RULE",
|
||||
"invalidPolicy": "{target} 是一个无效的策略",
|
||||
"invalidProxyProvider": "{providerName} 是一个无效的代理集",
|
||||
"invalidRuleSet": "{ruleSet} 不是有效的规则集",
|
||||
"invalidNetworkContent": "仅支持 tcp 或 udp",
|
||||
"invalidRangeContent": "请输入数字或范围,如 80 或 8000-9000,多个用 / 分隔",
|
||||
"invalidDscpContent": "DSCP 标记不能超过 63",
|
||||
"invalidProxy": "{proxyName} 是一个无效的代理",
|
||||
"proxyGroupDetectedAbnormal": "检测到当前策略组异常",
|
||||
"proxyProviderDetectedAbnormal": "检测到选中的代理集存在异常",
|
||||
"proxyDetectedAbnormal": "检测到选中的代理存在异常",
|
||||
"createProfile": "创建配置",
|
||||
"locationPermissionRequired": "需要位置权限",
|
||||
"locationPermissionGuide": "1. 打开 系统设置 > 隐私与安全性\n2. 选择 定位服务\n3. 在右侧列表中找到并勾选 {appName}\n\n完成设置后,返回应用即可正常使用。感谢您的配合。",
|
||||
"prerequisites": "前置条件",
|
||||
"ignoreBatteryOptimization": "忽略电池优化",
|
||||
"batteryOptimizationDesc": "为保证后台运行,请关闭本应用的电池优化。点击前往设置。",
|
||||
"batteryOptimizationStatusTip": "受系统影响,不代表一定准确",
|
||||
"batteryOptimizationStatusTip": "由于系统限制,运行状态下无法正确获取电池优化状态",
|
||||
"locationPermission": "位置权限",
|
||||
"locationPermissionDesc": "根据系统要求,获取Wi-Fi名称需要您授予位置权限。",
|
||||
"locationPermissionDesc": "根据系统要求,获取Wi-Fi名称需要您授予位置权限。Android 上请选择“始终允许”,否则应用在后台时无法获取 Wi-Fi 名称。",
|
||||
"excludeSsids": "排除SSIDs",
|
||||
"excludeSsidsDesc": "连接到被排除SSID的WIFI时,将会自动切换应用运行状态",
|
||||
"ssidsEmpty": "SSIDs为空",
|
||||
@@ -541,7 +667,7 @@
|
||||
"editSsid": "编辑SSID",
|
||||
"authorized": "已授权",
|
||||
"tapToAuthorize": "点击授权",
|
||||
"suspended": "挂起中...",
|
||||
"suspended": "挂起中…",
|
||||
"geoOptions": "Geo 选项",
|
||||
"geoAutoUpdate": "自动更新",
|
||||
"geoAutoUpdateInterval": "自动更新间隔",
|
||||
@@ -549,9 +675,107 @@
|
||||
"hours": "小时",
|
||||
"hoursCount": "{count} 小时",
|
||||
"geoResources": "Geo 资源",
|
||||
"geoUpdating": "正在更新 {name}...",
|
||||
"geoSkipped": "{name} 已是最新版本",
|
||||
"geoUpdated": "{name} 已更新",
|
||||
"secondsCount": "{count} 秒",
|
||||
"entriesCount": "{count} 个条目"
|
||||
"proxiesCount": "{count} 个代理",
|
||||
"rulesCount": "{count} 条规则",
|
||||
"changelogBreaking": "重大变更",
|
||||
"changelogFeatures": "新功能",
|
||||
"changelogFixes": "问题修复",
|
||||
"changelogPerformance": "性能优化",
|
||||
"changelogReverts": "已回滚",
|
||||
"close": "关闭",
|
||||
"back": "返回",
|
||||
"minimize": "最小化",
|
||||
"maximize": "最大化",
|
||||
"unmaximize": "向下还原",
|
||||
"pinWindow": "窗口置顶",
|
||||
"unpinWindow": "取消置顶",
|
||||
"exitFullScreen": "退出全屏",
|
||||
"torch": "手电筒",
|
||||
"pickFromAlbum": "从相册选择",
|
||||
"blockConnection": "阻止连接",
|
||||
"closeConnections": "关闭连接",
|
||||
"scrollToSelected": "滚动到已选",
|
||||
"showMore": "展开",
|
||||
"showLess": "收起",
|
||||
"previousMatch": "上一个匹配",
|
||||
"nextMatch": "下一个匹配",
|
||||
"clearSearch": "清除搜索",
|
||||
"noSearchResults": "没有匹配的结果",
|
||||
"addWidget": "添加组件",
|
||||
"showPassword": "显示密码",
|
||||
"hidePassword": "隐藏密码",
|
||||
"authorize": "授权",
|
||||
"installedAppsPermissionRequired": "需要读取应用列表权限",
|
||||
"installedAppsPermissionDesc": "当前系统在授权前不会提供已安装的应用列表,授权后即可配置分应用代理。",
|
||||
"installedAppsPermissionDeniedMessage": "读取应用列表权限已被拒绝,无法获取已安装的应用。请前往系统设置手动开启。",
|
||||
"outboundIp": "出站 IP",
|
||||
"ipAddress": "IP 地址",
|
||||
"ipQualityLevel": "等级",
|
||||
"ipQualityGood": "优",
|
||||
"ipQualityNormal": "普通",
|
||||
"ipQualityRisky": "风险",
|
||||
"ipType": "类型",
|
||||
"ipTypeResidential": "住宅",
|
||||
"ipTypeMobile": "移动网络",
|
||||
"ipTypeBusiness": "商业",
|
||||
"ipTypeHosting": "机房",
|
||||
"ipFlags": "命中标记",
|
||||
"ipFlagProxy": "代理",
|
||||
"ipFlagVpn": "VPN",
|
||||
"ipFlagTor": "Tor",
|
||||
"ipFlagAbuser": "滥用记录",
|
||||
"ipOrganization": "组织",
|
||||
"ipAsn": "ASN",
|
||||
"ipQualitySource": "采用来源",
|
||||
"ipQualitySources": "各来源",
|
||||
"ipQualityFailed": "类型查询失败",
|
||||
"ipQualityRetry": "重新查询",
|
||||
"ipSourceNoType": "无法判定类型",
|
||||
"ipSourceRateLimited": "限流",
|
||||
"ipSourceIpMismatch": "出站 IP 不一致",
|
||||
"hideIp": "隐藏 IP",
|
||||
"editorUnavailable": "编辑器不可用",
|
||||
"replace": "替换",
|
||||
"replaceAll": "全部替换",
|
||||
"cameraPermissionRequired": "需要相机权限",
|
||||
"cameraPermissionDesc": "请在系统设置中允许访问相机以扫描二维码,或从相册选择二维码图片。",
|
||||
"cameraUnavailable": "相机不可用",
|
||||
"retry": "重试",
|
||||
"invalidProfileQrcode": "该二维码不包含配置文件链接",
|
||||
"dnsQueries": "DNS查询",
|
||||
"recordType": "记录类型",
|
||||
"answers": "应答",
|
||||
"responseCode": "响应码",
|
||||
"error": "错误",
|
||||
"initiator": "发起方",
|
||||
"cache": "缓存",
|
||||
"proxyNode": "代理节点",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"nonTextProviderFile": "该外部资源不是文本文件",
|
||||
"addCustomProxy": "添加代理",
|
||||
"proxyDefinition": "完整配置",
|
||||
"proxyDefinitionNotMap": "配置必须是包含 name 和 type 的 YAML 映射",
|
||||
"customProxiesEmpty": "未添加自定义代理,沿用配置文件自身的代理",
|
||||
"overwriteIssueEmptyName": "名称为空",
|
||||
"overwriteIssueReservedName": "{name} 是内置策略名,不能在此使用",
|
||||
"overwriteIssueDuplicateName": "名称 {name} 已被其他代理或策略组使用",
|
||||
"overwriteIssueCoreRejected": "内核无法解析该代理:{message}",
|
||||
"overwriteIssueMissingProxies": "以下代理或策略不存在:{names}",
|
||||
"overwriteIssueMissingProviders": "以下代理集不存在:{names}",
|
||||
"overwriteIssueNoProxySource": "未选择任何代理或代理集,内核会拒绝该策略组",
|
||||
"overwriteIssueGroupLoop": "策略组之间存在循环引用:{path}",
|
||||
"overwriteIssuesSummary": "{count} 项存在问题,应用覆写可能失败",
|
||||
"overwriteIssueSubscriptionGroupMissingProxies": "配置文件自身的策略组引用了自定义代理中已没有的代理:{names}",
|
||||
"providerInUse": "{label} 仍被 {profiles} 的自定义策略组或规则使用,请先在那里移除",
|
||||
"providerSourceSubscription": "订阅",
|
||||
"providerRenameShadowed": "{profiles} 的订阅里已有 {label},改名后这些配置会改用订阅里的那个,请换一个名称",
|
||||
"fontSize": "大小",
|
||||
"large": "大",
|
||||
"extraLarge": "超大",
|
||||
"switchProfile": "切换配置",
|
||||
"lastUpdated": "上次更新"
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,23 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user