Compare commits
1 Commits
dev
..
v0.8.95-pre.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f9fe094bf |
@@ -4,12 +4,11 @@ Use the smallest durable surface that matches the job.
|
||||
|
||||
## Surfaces
|
||||
|
||||
- `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`: auto-loaded repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
|
||||
- `.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 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.
|
||||
- `.agents/skills/*/SKILL.md`: repo-scoped Codex skills. Use these for repeatable workflows that should be discoverable by name and description.
|
||||
- `.codex/config.toml`: trusted project Codex settings such as MCP, hooks, sandbox, approval, or model defaults.
|
||||
- `.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/rules/*.rules`: command permission rules for running commands outside the sandbox. 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
|
||||
@@ -18,9 +17,6 @@ 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
|
||||
|
||||
+128
-1701
File diff suppressed because it is too large
Load Diff
+33
-304
@@ -17,55 +17,39 @@ dart setup.dart windows
|
||||
dart setup.dart android
|
||||
```
|
||||
|
||||
The desktop 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/`. Artifacts land in `libclash/`. Android's Core is
|
||||
the AAR the `:core` Gradle module builds with `gomobile bind` before anything
|
||||
compiles against it; that needs `go` on `PATH` and nothing else, since the
|
||||
gomobile tools come from `core/go.mod` and the NDK from AGP. 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:
|
||||
Build only the Go core and skip Flutter packaging:
|
||||
|
||||
```bash
|
||||
rm -rf .dart_tool/setup_build_cache
|
||||
make core-macos
|
||||
make core-linux
|
||||
make core-windows
|
||||
make core-android
|
||||
```
|
||||
|
||||
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.
|
||||
Pass `ARCH` or `TARGET_PLATFORM` through `make` when needed, for example:
|
||||
|
||||
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
|
||||
```bash
|
||||
make core-macos ARCH=arm64
|
||||
make core-android TARGET_PLATFORM=android-arm64
|
||||
```
|
||||
|
||||
CI flips `setup` to `false` with `yq` in its throwaway checkout before
|
||||
`dart run tool/changelog.dart` and `dart run tool/test.dart`; no test loads the Go core. `rust_api`
|
||||
stays on there because the editor tests in `test/plugins/code_forge/` and the socket
|
||||
test in `test/core/desktop/ipc_native_test.dart` 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 and IPC socket 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.
|
||||
Core builds use setup's input fingerprint cache. Pass `FORCE=1` to bypass it,
|
||||
for example `make core-macos ARCH=arm64 FORCE=1`.
|
||||
|
||||
The Makefile wraps `plugins/setup/buildkit/run_build_tool.sh`; prefer the `make` entry points unless debugging the build tool itself.
|
||||
|
||||
## Flutter Development
|
||||
|
||||
Use the default Flutter SDK directly:
|
||||
The project follows FVM's `stable` channel locally. Release CI pins an exact
|
||||
Flutter version separately; see `.agents/project.md`.
|
||||
|
||||
```bash
|
||||
fvm flutter pub get
|
||||
fvm flutter run
|
||||
fvm flutter test
|
||||
```
|
||||
|
||||
Plain Flutter also works when the global SDK matches project constraints:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
@@ -75,23 +59,6 @@ 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:
|
||||
@@ -107,37 +74,12 @@ 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.
|
||||
@@ -145,7 +87,6 @@ Tests use `package:test/test.dart` for pure Dart logic and `flutter_test` for pr
|
||||
```bash
|
||||
flutter test test/models/
|
||||
flutter test test/core/
|
||||
flutter test test/core/desktop/
|
||||
flutter test test/providers/
|
||||
flutter test test/common/
|
||||
flutter test test/database/
|
||||
@@ -154,235 +95,23 @@ flutter test test/setup_test.dart
|
||||
flutter test plugins/proxy/test/proxy_test.dart
|
||||
```
|
||||
|
||||
For the whole suite, or a directory of more than a few files, use `tool/test.dart`, which is also what CI runs:
|
||||
|
||||
```bash
|
||||
dart run tool/test.dart # all of test/
|
||||
dart run tool/test.dart test/widgets test/views # some paths
|
||||
dart run tool/test.dart -- --reporter expanded --coverage
|
||||
```
|
||||
|
||||
Plain `flutter test` compiles each file into its own program and starts an engine for it, one file after another, and
|
||||
that dominates a full run: 329 files took 128 s on a 12-core Mac with at most four running at once. `tool/test.dart`
|
||||
imports the files into 32 generated entrypoints under `.dart_tool/test_bundles/` and runs those with one worker per
|
||||
core: about 60 s for the same run, and 67 s instead of 180–213 s with `--coverage`. Files in one entrypoint share an
|
||||
isolate, so a test can see state a file before it left behind; the script runs every file that failed there again on
|
||||
its own, lets that run decide, and names the files that only failed together. See the Shared Test Isolate section of
|
||||
`rules.md` before fixing one.
|
||||
|
||||
`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 branch push, and by the release matrix on a tag; `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:
|
||||
|
||||
```bash
|
||||
flutter test test/core/desktop/
|
||||
flutter test test/core/service_test.dart
|
||||
flutter test test/core/protocol_contract_test.dart
|
||||
flutter test test/manager/core_manager_test.dart
|
||||
flutter test test/providers/action_test.dart test/providers/system_action_test.dart
|
||||
flutter test test/widgets/core_status_button_test.dart
|
||||
```
|
||||
|
||||
What those suites own:
|
||||
|
||||
- `test/core/desktop/`: replaceable IPC transport, RPC request correlation/failure, direct/Helper process leases, and
|
||||
latest-intent desktop lifecycle convergence. `ipc_native_test.dart` runs the transport over a real socket through
|
||||
the Rust `IpcServer`; the server's own behavior is covered by `cargo test` in `plugins/rust_api/rust`.
|
||||
- `test/core/service_test.dart`: `CoreService` composition and terminal close behavior.
|
||||
- `test/core/protocol_contract_test.dart`: shared Dart/Go method and event-envelope compatibility, including event batches.
|
||||
- `test/providers/action_test.dart`: Core start/restart orchestration and overlapping restart requests.
|
||||
- `test/providers/system_action_test.dart`: ordered, idempotent exit cleanup and watchdog behavior.
|
||||
- `test/widgets/core_status_button_test.dart`: 600-millisecond connecting presentation hold, immediate failure display,
|
||||
long-running connecting state, and disconnected restart.
|
||||
|
||||
## Native Component Verification
|
||||
|
||||
The CI Go-wrapper checks can be reproduced without CGO, the Android files included:
|
||||
|
||||
```bash
|
||||
cd core
|
||||
CGO_ENABLED=0 go test .
|
||||
CGO_ENABLED=0 go vet . ./cmd/...
|
||||
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go vet -tags=with_gvisor . ./mobile ./tun ./platform
|
||||
```
|
||||
|
||||
The Windows Helper's loopback/session protocol tests are host-independent by default. Windows CI additionally enables its
|
||||
service implementation:
|
||||
|
||||
```bash
|
||||
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
cargo test --manifest-path services/helper/Cargo.toml
|
||||
cargo test --manifest-path services/helper/Cargo.toml --features windows-service
|
||||
```
|
||||
|
||||
The last command requires Windows for meaningful service coverage. Native Android lifecycle edits should at minimum
|
||||
compile the modules they touch, 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
|
||||
export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home" # elsewhere: any JDK 17
|
||||
./gradlew :core:compileDebugKotlin -Ptarget-platform=android-arm64 # builds the gomobile AAR for one ABI
|
||||
./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.
|
||||
|
||||
## Changelog And Release
|
||||
|
||||
The changelog is derived from Conventional Commits by `tool/changelog.dart` and written to two committed files:
|
||||
`CHANGELOG.md` for readers and `changelog.json` for the renderers. See `.agents/rules.md` for the `Changelog:` trailers
|
||||
that decide the wording.
|
||||
|
||||
The app ships no changelog of its own. `render release` appends the released version as JSON inside an HTML comment
|
||||
(`<!-- flclash:changelog:json … -->`), so the release body GitHub already returns to `checkForUpdate` carries the
|
||||
notes shown in the update dialog. `parseReleaseChangelog` reads that block and falls back to the English
|
||||
bullets when a release predates it.
|
||||
|
||||
```bash
|
||||
dart run tool/changelog.dart verify # what CI checks
|
||||
dart run tool/changelog.dart release --version 0.8.96
|
||||
dart run tool/changelog.dart build --unreleased # changelog.json only, includes untagged work
|
||||
dart run tool/changelog.dart render release --out release.md
|
||||
dart run tool/changelog.dart render telegram --out telegram.md
|
||||
```
|
||||
|
||||
Releasing a stable version, in order:
|
||||
|
||||
```bash
|
||||
tool/bump_version.sh all
|
||||
dart run tool/changelog.dart release --version 0.8.96
|
||||
git commit -am "chore(release): v0.8.96"
|
||||
git tag v0.8.96
|
||||
git push origin main && git push origin v0.8.96
|
||||
```
|
||||
|
||||
Push the tag by name. Every release tag here is lightweight, and `--follow-tags` carries annotated tags only: it skips a
|
||||
lightweight one silently, so the branch lands, the tag does not, and the release workflow never fires.
|
||||
|
||||
`tool/release.sh` drives both paths so the ordering below cannot be got wrong by hand. It resolves the version (bumping
|
||||
the patch when pubspec still names an already tagged one), prints the notes the tag would ship, and only pushes with
|
||||
`--push`:
|
||||
|
||||
```bash
|
||||
tool/release.sh pre --dry-run # plan and notes, changes nothing
|
||||
tool/release.sh pre --push # bump, tag vX.Y.Z-pre.N, push
|
||||
tool/release.sh stable --push # changelog, chore(release) commit, tag, push
|
||||
```
|
||||
|
||||
The release commit comes before the tag on purpose: the generated wording is reviewable in the diff before it ships, and
|
||||
the tag is what `render release` reads. CI never writes back to the repository; it only runs `verify`. Wording in
|
||||
`changelog.json` may be edited by hand as long as no derivable entry disappears and every entry still points at a commit
|
||||
inside that version's range.
|
||||
|
||||
Entries at or below `v0.8.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`, plus the GitHub star count from `release.json` so the
|
||||
nav does not grow when the page loads. `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`.
|
||||
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`.
|
||||
|
||||
## 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:
|
||||
The pull-request and release workflows run these root-package checks in order:
|
||||
|
||||
```bash
|
||||
bash tool/hooks/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
|
||||
flutter test --reporter expanded
|
||||
```
|
||||
|
||||
`tool/hooks/` holds the scripts the git hooks run, and `tool/hooks/test.sh` runs every `*_test.sh` beside them, so a
|
||||
new hook script needs only its test file next to it, not a workflow step.
|
||||
Run `flutter analyze` locally before committing when practical.
|
||||
|
||||
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 parallel jobs validate the rest from their own package
|
||||
directories: `plugins` (local Flutter packages and the setup build tool), `go`
|
||||
(the Core wrapper, plus a vet of the Android files), `android` (JVM unit tests
|
||||
for `:common`, `:service` and `:app`, with the Flutter compile tasks excluded
|
||||
and the gomobile AAR built for one ABI), `rust` (both crates), and a
|
||||
Windows runner for the helper's `windows-service` feature. On branch pushes a
|
||||
`desktop` job also compiles a debug build per desktop platform; a tag skips it,
|
||||
since its release matrix compiles the same native code. Release builds run only
|
||||
in the public repository and start alongside the checks; `upload` publishes
|
||||
only once every build and every check job 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.
|
||||
|
||||
### CI Caches
|
||||
|
||||
A run restores only cache entries saved on its own branch or tag, or on the default branch (`main`), and the
|
||||
repository holds 10 GB before evicting the least recently used entries. Every release is a new tag that no later run
|
||||
reads from, so `BRANCH_CACHE` turns caching off in tag runs: one release used to write about 9 GB of such entries and
|
||||
evict everything `dev` had saved. Release builds therefore start cold; branch runs, chiefly `dev`, keep the caches.
|
||||
|
||||
- setup-go keys its entry on the `cache-dependency-path` files alone and saves only on a miss, so jobs listing the
|
||||
same files share one entry holding the build cache of whichever finished first. `desktop` and `android` each add
|
||||
the file that drives their Core build to get an entry of their own.
|
||||
- setup-gradle writes only on the default branch unless told otherwise, so `android` lets `dev` write as well. Its
|
||||
entries are keyed by job, which is one more reason the release build gains nothing from caching.
|
||||
|
||||
## 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
|
||||
```
|
||||
The test job runs for pull requests and `v*` tag pushes. Release builds remain
|
||||
tag-only. 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.
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
# 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.6).
|
||||
- **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.
|
||||
|
||||
## Wrong-size back buffer on macOS
|
||||
|
||||
- **Bug:** [flutter/flutter#185394](https://github.com/flutter/flutter/issues/185394). `removeSurfaceForSize:` in
|
||||
the macOS engine's `FlutterSurfaceManager.mm` hands out the youngest idle cached surface without checking its size.
|
||||
When the window flips between sizes while the window server still holds the previous surface, which dragging a
|
||||
window edge back and forth does, a request for one size gets a surface of another. Impeller's Metal texture then
|
||||
disagrees with its descriptor, the render target drops it, and `Canvas::SetupRenderPass` dereferences a null
|
||||
texture on the raster thread. Debug builds log `The texture and its descriptor disagree about its size` and
|
||||
`Store action needs resolve but no valid resolve texture specified` first; release builds just crash.
|
||||
- **Fix upstream:** [flutter/flutter#192522](https://github.com/flutter/flutter/pull/192522), still open; 3.47.6
|
||||
ships the same `FlutterSurfaceManager.mm` as 3.47.4.
|
||||
- **Workaround:** `BackBufferCacheGuard` in the `window` plugin's
|
||||
`macos/window/Sources/window/BackBufferCacheGuard.swift`, installed from `WindowPlugin.register`, replaces
|
||||
`-[FlutterBackBufferCache removeSurfaceForSize:]` at runtime and answers nil when the cached surface is not the
|
||||
requested size, so the engine allocates a new one. It does nothing if the engine no longer has that class or
|
||||
selector.
|
||||
- **Remove when:** `FLUTTER_VERSION` moves to a stable that contains #192522 (check the SDK `CHANGELOG.md` for a
|
||||
3.47.x cherry-pick). Before dropping it, run a debug build without the guard on that SDK and drag a window edge
|
||||
back and forth for a minute; neither validation line may appear.
|
||||
|
||||
## 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`.
|
||||
|
||||
## Liquid glass saves around the transform of a blurring layer
|
||||
|
||||
- **Bug:** not filed upstream. On 3.47 Impeller scales a `saveLayer`'s image filter by a transform set inside the
|
||||
layer that is still in effect when the layer is restored. A host's capture shrinks each pane's slot inside the
|
||||
layer that blurs it, so its blur vanished at a quarter scale and spread half again as far at 1.5.
|
||||
- **Workaround:** `_LiquidGlassHostLayer._snapshot` sets each slot's transform inside a `save` of its own, and each
|
||||
band's offset, restored before the layer is.
|
||||
- **Remove when:** without that `save`, the frosted-pane case of `test/widgets/liquid_glass_host_test.dart` passes
|
||||
under `flutter test --enable-impeller`.
|
||||
+8
-82
@@ -1,101 +1,27 @@
|
||||
# Project Context
|
||||
|
||||
FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with Flutter. It supports Android, Windows, macOS, and Linux, using Material You from `material_ui` as the base with Apple's Human Interface interaction conventions layered on top.
|
||||
FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with Flutter. It supports Android, Windows, macOS, and Linux, using a Material You design with Surfboard-like UI.
|
||||
|
||||
## Version Notes
|
||||
|
||||
- Flutter stays on the 3.47.x line. Release CI pins 3.47.6 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
|
||||
|
||||
One `pubspec.yaml` dependency is pinned to a fork by commit SHA. The fork lives
|
||||
under `chen08209`, the same account that owns this repository, so it is
|
||||
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 the 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.
|
||||
|
||||
The `yaml_writer` fork (`chen08209/yaml_writer`) was replaced by
|
||||
`lib/common/yaml_writer.dart`. Its key quoting let keys such as `!a`, `'a` or
|
||||
`0x1F` reach the Core's yaml.v3 as a parse error or a non-string; a merge key
|
||||
`<<` stays plain on purpose, because `package:yaml` keeps it as an ordinary key
|
||||
and the Core applies the merge.
|
||||
|
||||
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.
|
||||
|
||||
The `riverpod` set, `build_runner`, `drift_dev` and `intl_utils` resolve to their
|
||||
latest releases. Two are held back:
|
||||
|
||||
- `freezed` 4.0.2 needs `analyzer` 14, while `intl_utils` 2.8.16 still requires
|
||||
`analyzer` ^13. It moves once `intl_utils` accepts analyzer 14.
|
||||
- `test` 1.32.0 needs `test_api` 0.7.14, and `flutter_test` on Flutter 3.47 pins
|
||||
0.7.12. It moves with the SDK.
|
||||
|
||||
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.
|
||||
- `.fvmrc` follows the FVM `stable` channel for local development; it does not
|
||||
pin an immutable Flutter version.
|
||||
- Release CI pins Flutter 3.44.4. Local `stable` may diverge, so trust the CI
|
||||
version as the source of truth for release builds.
|
||||
- Dart SDK constraint: `>=3.8.0 <4.0.0`.
|
||||
|
||||
## Build Dependencies
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
sudo apt-get install libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
- GCC and Inno Setup.
|
||||
- `ANDROID_NDK` env var for Android builds.
|
||||
|
||||
macOS:
|
||||
|
||||
|
||||
+7
-613
@@ -4,11 +4,7 @@ These are repository coding and testing conventions. Codex command permission ru
|
||||
|
||||
## Dart and Flutter Style
|
||||
|
||||
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:
|
||||
`analysis_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.
|
||||
@@ -17,519 +13,30 @@ individual `analysis_options.yaml`; those files carry only their own `analyzer.e
|
||||
- `prefer_const_constructors: true` and `prefer_const_declarations: true`.
|
||||
- `prefer_final_locals: true` and `prefer_final_in_for_each: true`.
|
||||
- `always_declare_return_types: true`.
|
||||
- `only_throw_errors: true`: throw an `Exception` or `Error`, never a bare `String`.
|
||||
|
||||
Failures whose whole content is a message meant for the user throw
|
||||
`MessageException` from `lib/common/exception.dart`. Its `toString()` is the bare
|
||||
message, which is what `globalState.safeRun` surfaces in the dialog, so the
|
||||
user-facing text is unchanged from the older `throw someMessage` idiom while the
|
||||
throw stays catchable as an `Exception` and carries a stack trace. Assert on it
|
||||
with `isA<MessageException>().having((e) => e.message, 'message', ...)`, not on a
|
||||
raw string.
|
||||
|
||||
### Corner Radius
|
||||
|
||||
Rounded corners are superellipses (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. Framework widgets that paint their own circular corners with no shape or
|
||||
decoration to override, such as the `Switch` track, are left as Material draws them. Tooltips take theirs from
|
||||
`withAppShapes`, which sets `tooltipTheme.decoration`.
|
||||
|
||||
### Typography
|
||||
|
||||
Text sizes follow Apple's iOS Dynamic Type scale at its default size, on every platform. `AppTextStyle` in
|
||||
`lib/ui/typography.dart` holds its eleven styles, `largeTitle` to `caption2`, with Apple's leading and weights, plus two
|
||||
steps the scale lacks: `display` for a dashboard figure and `pageTitle` for an app bar. Take one with
|
||||
`AppTextStyle.body.of(context)`, pass `emphasized: true` for the scale's emphasized weight (bold for the titles,
|
||||
semibold for the rest), and change color or family with `copyWith`; a weight is either regular or emphasized. Text
|
||||
sized to the shape it marks, such as a letter scaled to its icon, comes from `AppTextStyle.custom`. The dashboard's
|
||||
roles (`DashboardText` in `lib/views/dashboard/style.dart`) are built on the same types. `ThemeData.withAppTypography`
|
||||
maps Material's `TextTheme` slots onto the scale so Material components follow it; app code never reads those slots.
|
||||
|
||||
App bar titles sit at the leading edge in `pageTitle` on a root page and on any desktop page, at the leading edge in
|
||||
`headline` on a pushed page in the mobile view, and in the bar's centre in `headline` on a bottom sheet. A title widget,
|
||||
such as the editor's name field, stays at the leading edge. Past a back button a title keeps the bar's action inset
|
||||
clear of the button on either side.
|
||||
|
||||
`test/lint/typography_test.dart` fails on `textTheme` anywhere in `lib/` but `typography.dart`, and on `fontSize:` or
|
||||
`fontWeight:` outside `lib/ui/`, except in the code editor, whose text follows the editor font-size setting.
|
||||
|
||||
### Text Field Suggestions
|
||||
|
||||
On Android, Flutter maps `enableSuggestions: false` to the visible-password input type, not only to "no suggestions"
|
||||
(`TextInputPlugin.inputTypeFromTextInputType` in the engine). Input methods then treat the field as a password: no
|
||||
clipboard panel, no Chinese layouts, and on some systems a secure keyboard. With the app's clipboard permission denied,
|
||||
that leaves no way to paste a link at all. Only the secret fields, `SecretTextFormField` and `FormRowSecretTextField`,
|
||||
ask for a password keyboard; a link, key, rule or regex field that should not be corrected sets `autocorrect: false`
|
||||
alone. `test/lint/text_field_suggestions_test.dart` fails on `enableSuggestions: false` or
|
||||
`TextInputType.visiblePassword` anywhere else in `lib/`.
|
||||
|
||||
### Length Units
|
||||
|
||||
Flutter lengths are logical pixels, the unit Android calls dp. Comments, test names, docs and reports give them in dp,
|
||||
as in `48 dp`, never px: a reader takes px for physical pixels and misjudges the size by the device pixel ratio. Write
|
||||
"physical pixel" in full where the code really works in them, as the hairline snapping in
|
||||
`lib/ui/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in
|
||||
`tool/` and CSS under `site/` keep px. `test/lint/length_unit_test.dart` fails on a number followed by px in `lib/`
|
||||
and `test/`.
|
||||
|
||||
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:
|
||||
Generated 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 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.
|
||||
|
||||
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.
|
||||
|
||||
### 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 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/ui/open_container.dart`.
|
||||
|
||||
### Density
|
||||
|
||||
`tool/hooks/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/hooks/comment_density_test.sh`, which CI runs through `tool/hooks/test.sh`.
|
||||
|
||||
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
|
||||
|
||||
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, 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/delay.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.
|
||||
|
||||
## 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 `admit` in `ipc/access.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, the
|
||||
gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches. A response whose
|
||||
`file` is set was spilled to disk; a transport resolves it with `resolveSpilledResponse` before reading `result`.
|
||||
- `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.
|
||||
- Every method of the callback interfaces in `core/mobile` (`TunHandler`, `ResultHandler`) returns an `error`. gobind
|
||||
clears a Java exception and hands it to Go only for a method that declares one; otherwise the exception stays pending
|
||||
and the next JNI call on that thread aborts the process, so a throw in `protect`/`resolveUid`/`resolvePackage`/
|
||||
`onResult` would crash unrelated code. `core/mobile` turns the error into the answer the system gives when it will not
|
||||
help: `false`, `-1`, or an empty package name.
|
||||
- `TunHandler`'s hooks in `core/lib.go` check `callbacks` under `th.mu` before every call. `clear` drops the callbacks
|
||||
while connections may still be resolving, and a call through the nil interface panics on a mihomo goroutine, outside
|
||||
every recovery.
|
||||
- The Android bridge resolves an owner in two steps — `ResolveUid` then `ResolvePackage` — 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 are plain functions that return their answer, registered in the `methodHandlers` table in
|
||||
`core/method.go` through the adapter matching their shape. `dispatchMethodCall` already runs every call on its own
|
||||
goroutine and `handleMethodCall` turns a panic into an `internal_error` answer, so a handler blocks for as long as its
|
||||
work takes and never spawns a goroutine just to answer. Work that outlives the answer goes through `safeGo`, which
|
||||
recovers; a bare `go` puts a panic outside every recovery and kills the process, which on Android is the whole
|
||||
application. The exported Android entry points in `core/lib.go` do not reach `handleMethodCall`, so each one carries
|
||||
its own recovery.
|
||||
- A handler fails by returning an `error`: a `*MethodError` travels as it is and anything else under `core_error`. The
|
||||
exception is `withMessage`, for the methods whose result the host reads as a message that is empty on success
|
||||
(`_invokeMessage` in `lib/core/interface.dart`): there a plain error becomes that message and only a `*MethodError`
|
||||
fails the call. Moving a method between the two changes what Dart receives.
|
||||
- `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.
|
||||
- Some constants in `lib/common/constant.dart` mirror the Core and move with it. `delayTimedOutValue` is the `-1` that
|
||||
`core/delay.go` reports for a delay test that ran out of time; any other negative value failed outright.
|
||||
`downloadTimeoutDuration` matches mihomo's `resource.DefaultHttpTimeout`, which bounds the Core's provider downloads
|
||||
end to end. `shareLinkSchemes` are the schemes mihomo's `ConvertsV2Ray` reads, less http(s), which is a subscription.
|
||||
- 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.
|
||||
- No file in `core/` uses cgo: the only C in the Android library is the glue gobind generates during `gomobile bind`.
|
||||
Android-only files take `//go:build android` and desktop-only ones `!android`, so `GOOS=android CGO_ENABLED=0 go vet`
|
||||
checks the Android side without an NDK. Keep it that way; cgo in `core/` puts the NDK back in front of that vet and of
|
||||
`go build ./...` on every developer machine.
|
||||
|
||||
## 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 no `ApplicationExitInfo` equivalent to attribute an
|
||||
exit, 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.
|
||||
- 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
|
||||
stay centralized in `SystemAction`/`SystemExitCoordinator`.
|
||||
- Android start/stop MethodChannel calls are optimistic UI commands. Keep latest-wins arbitration in native
|
||||
`ServiceState`; do not add a Flutter completion callback that creates a second lifecycle owner. What flows back is
|
||||
the revision-stamped `stopped` report, which Flutter mirrors with `setRunning(false)` and never answers with a
|
||||
decision of its own.
|
||||
- Android `RunState` is derived in `ServiceStateMachine.publish`; do not assign it from a transition step, and do not
|
||||
add a second "is it running" field beside `ServiceController.runtime`. Every divergence between the tile and the
|
||||
service so far came from a copy that one path forgot to update.
|
||||
- Android service callbacks are not automatically user intent. Route explicit Quick Settings, Always-on VPN, and revoke
|
||||
actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.
|
||||
- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the
|
||||
broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.
|
||||
- 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 accepted 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. Windows accepts a payload the shell refused, as at sign-in, and retries the icon
|
||||
natively until it lands, so `Tray` still treats it as visible and `hide` reaches native to delete an add that may
|
||||
have landed. 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` calls into the Core
|
||||
only when the resolved list or the preferred network changed, and `stop()` clears the handler queue, so a network that
|
||||
reports `onLosing` repeatedly costs two comparisons per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds
|
||||
state that has to stay in sync with the map.
|
||||
|
||||
## UI Isolate Work
|
||||
|
||||
Everything the UI isolate runs synchronously shares a frame with build, layout and paint: 16 ms at 60 Hz, 8 ms at
|
||||
120 Hz. Computation may take as long as it needs anywhere else, so this rule is about where work runs, not how much of
|
||||
it there is.
|
||||
|
||||
- Work that grows with the user's data (profiles, rules, proxies, groups, dialers, icons, connections) and can take
|
||||
more than about 8 ms on the largest data the app expects runs in an isolate, through `compute` or `Isolate.run`.
|
||||
Thousands of rules or proxies and hundreds of groups are in that range. `makeRealProfileTask` in
|
||||
`lib/common/task.dart` and the backup tasks in `lib/common/backup.dart` are the existing shape.
|
||||
- Make the algorithm linear or cached first. Work superlinear in that data, such as a walk of the whole graph per
|
||||
item, a scan of every group per group, or a list copy per inserted element, is a defect on any isolate; moving it
|
||||
only hides the cost until the data outgrows the move.
|
||||
- Sending data to an isolate copies it on the sending isolate, so hand the task the smallest input it reads. A value
|
||||
the UI reads synchronously, such as a sync provider watched in `build`, becomes an async provider with a loading
|
||||
state rather than a blocking call.
|
||||
- drift runs SQL on its own isolate but maps rows into models on the caller's, so a watched query over a large table
|
||||
costs the UI isolate on every emission, and any write to that table makes it emit again.
|
||||
- Bounded work stays where it is: a provider recompute that reuses a cache, a single-item update, a search filter over
|
||||
a list sized for scrolling.
|
||||
- Decide on measurements taken at the expected maximum. A `flutter test` probe that counts provider recomputes, times
|
||||
the provider body and hooks `debugOnRebuildDirtyWidget` shows what reruns and how it scales; its JIT timings are not
|
||||
device numbers, so a case near the line is settled with a profile build. No gate checks this rule.
|
||||
|
||||
## 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,
|
||||
plus a `GOOS=android` vet that covers the `android` files the first two exclude; verify cross-language
|
||||
protocol behavior through shared Dart contract tests under `test/core/` and native platform build checks.
|
||||
The `core/` directory is excluded from automated test coverage. Do not add `*_test.go` files, coverage instrumentation,
|
||||
or coverage collection for code under `core/`. Verify core protocol behavior through shared Dart contract tests under
|
||||
`test/core/` and native platform build checks instead.
|
||||
|
||||
A Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` —
|
||||
leaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever
|
||||
connection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with
|
||||
`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test
|
||||
connection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches
|
||||
it in an unrelated test.
|
||||
Use `CoreController.test(mock)` to inject a mocked `CoreHandlerInterface`. Call `CoreController.resetInstance()` in `tearDown` to clean up the singleton between tests.
|
||||
|
||||
Register fallback values for freezed params used with `any()` matchers.
|
||||
|
||||
`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a
|
||||
group's floor when new tests lift it; do not lower one to make a run pass.
|
||||
|
||||
Every measured group needs a floor. A group the report measures but `_groupFloors` does not declare fails the run, so
|
||||
adding a top-level directory under `lib/` means adding its floor in the same change. Set a new floor at or just below
|
||||
the coverage the directory actually has; the point is to stop a slide, not to backfill tests before the directory can
|
||||
land.
|
||||
|
||||
Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and
|
||||
touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read
|
||||
resolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim
|
||||
the singleton calls `CoreController.resetInstance()` in `tearDown`. It also only reaches the global `coreController`
|
||||
when nothing has read it yet: that is a top-level `final`, bound to whichever instance existed at the first read, which
|
||||
in a shared isolate is another file's.
|
||||
|
||||
### Shared Test Isolate
|
||||
|
||||
`tool/test.dart` imports many test files into one entrypoint, so process-wide state outlives the file that set it, and
|
||||
every file's `main()` runs before any test of the entrypoint does. Keep `main()` to declarations and
|
||||
`TestWidgetsFlutterBinding.ensureInitialized()`: a mock installed there is replaced by the next file's before a test
|
||||
sees it. Set up what the tests need in `setUp` or `setUpAll` instead, from the helpers that rebind the singletons:
|
||||
|
||||
- `appPath` resolves its directories once per process. Point them at a test's directory with `await useAppPaths(dir)`
|
||||
from `test/helpers/app_paths.dart`, not with a `PathProviderPlatform` fake, which only works while nothing has asked
|
||||
`appPath` yet.
|
||||
- `preferences` opens its store once per process. Use `await mockPreferences(values)` from
|
||||
`test/helpers/preferences.dart` rather than `SharedPreferences.setMockInitialValues` alone, which leaves `preferences`
|
||||
on the store it already opened.
|
||||
|
||||
Both cache the resolved value, not the future that produced it. A future completed in one test's fake-async zone runs
|
||||
its listeners in that zone, so a later test that awaits it never resumes. Await the helpers outside `testWidgets`
|
||||
bodies for the same reason.
|
||||
|
||||
A test that only passes in a fresh isolate is leaning on a global it did not set up: `database_test.dart` once caught
|
||||
its write failures only because `currentAppLocalizations` threw before anything had loaded them. The script reruns such
|
||||
a file alone so it cannot fail the run, and lists it; fix it where it reads the global.
|
||||
|
||||
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
|
||||
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
|
||||
path. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`.
|
||||
|
||||
Three globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it:
|
||||
`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every
|
||||
`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry
|
||||
key), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early
|
||||
under `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test
|
||||
at all; `test/common/launch_test.dart` pins the early return instead.
|
||||
|
||||
`pumpAndSettle` never returns on a page holding `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 `test/providers/action_test.dart` does for `scriptsProvider`.
|
||||
|
||||
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.
|
||||
|
||||
`CommonScaffold` adds the height of its docked search to `BottomInsetScope` around its body, so a page that reads
|
||||
`BottomInsetScope.of(context)` with the context it builds the scaffold from gets the inset without it, and the end of
|
||||
its content stays under the search field. Read it below the scaffold,
|
||||
inside `body: Builder(builder: (context) => ...)`. `test/lint/scaffold_bottom_inset_test.dart` enforces this by
|
||||
scanning `lib/`.
|
||||
|
||||
Every reorderable list builds its drag proxy with `commonProxyDecorator`, which tells `DecorationListItem` it is lifted:
|
||||
the row drops its separator and rounds from its corners in the run to a card of its own as it lifts, and back as it
|
||||
drops. A list without it lifts a middle row with square edges. `test/lint/reorder_proxy_decorator_test.dart` enforces
|
||||
this by scanning `lib/`.
|
||||
|
||||
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:
|
||||
Use `ProviderContainer` directly for simple Riverpod provider tests. The generated Riverpod `update()` method takes a callback:
|
||||
|
||||
```dart
|
||||
notifier.update((state) => newValue);
|
||||
@@ -537,99 +44,6 @@ notifier.update((state) => newValue);
|
||||
|
||||
When testing freezed models with nested objects, always round-trip through `jsonEncode` and `jsonDecode`. Direct `fromJson(toJson())` fails for nested freezed types because `toJson()` stores child objects directly instead of maps.
|
||||
|
||||
For async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success,
|
||||
failure, disposal, and any timer boundary that changes visible state.
|
||||
|
||||
## 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/hooks/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.
|
||||
|
||||
## Migrations
|
||||
|
||||
An upgrade starts from the last release, not from the previous development build. Before writing a legacy reader, a
|
||||
data move, or a test for one, check that the state it reads shipped: `git tag --contains <commit>` on the commit that
|
||||
introduced it, or `git show <tag>:<path>` for the file as the release had it. A state that only ever existed between
|
||||
two releases gets no migration; a development install holding it may lose that setting or fall back to its default.
|
||||
|
||||
A release that resets or rewrites settings, instead of reading their old shape, adds a step to `_upgrades` in
|
||||
`lib/common/migration.dart` under the next `upgradeVersion`. A step takes and returns the `Config`, runs once on an
|
||||
install that already had settings, and never on a new one. Leave `currentVersion` for changes to the stored format:
|
||||
backups carry it, and an older app refuses a backup stamped newer than its own, so bumping it for a reset would strand
|
||||
backups between devices on different releases.
|
||||
|
||||
The database takes one migration step per release. The newest step in `lib/database/database.dart` has not shipped
|
||||
while `schemaVersion` at the last release tag is below its number; a schema change then goes into that step, raising
|
||||
`schemaVersion` and the step's condition together so a development install runs the step again, instead of adding a
|
||||
step of its own. Every operation in it checks the shape it finds, a missing table or column or a column under its old
|
||||
name, so it can run again on a database that went through part of it. Its tests start from the released shape.
|
||||
|
||||
## Generated Code
|
||||
|
||||
Do not manually edit generated files under:
|
||||
@@ -638,26 +52,6 @@ Do not manually edit generated files under:
|
||||
- `lib/models/generated/`
|
||||
- `lib/providers/generated/`
|
||||
- `lib/database/generated/`
|
||||
- `lib/ui/generated/`
|
||||
- `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,fr,es,ko}.arb` — flat JSON, no `@` metadata. Add a key to all seven, 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.
|
||||
|
||||
Labels for enum values go through the exhaustive switches in `lib/common/l10n_labels.dart`, never
|
||||
`Intl.message(<runtime string>)`. A key built from an enum name or a stored string is invisible to the analyzer, 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` fails on any `Intl.message(` in `lib` outside the generated code, and checks
|
||||
that every locale carries the same keys and has a display name.
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
# Setting Changes
|
||||
|
||||
A changed setting takes one of three paths. The path depends on who reads the setting and when it reads it, not on
|
||||
which page edits it. Choose the path when you add a setting or move one.
|
||||
|
||||
| Path | Who reads the setting | Watched provider | Acts | Effect |
|
||||
|---------|---------------------------------------------|----------------------------------------|-------------------------------|--------------------------------------------|
|
||||
| Update | Core, and Go can change it in place | `updateParamsProvider` | `CoreManager`, automatically | Patches the running config, no reload |
|
||||
| Apply | Core, only when it parses a config | `setupPatchProvider` | `CoreManager`, automatically | Rebuilds the profile and reloads the Core |
|
||||
| Restart | Android service, when it starts | `vpnOptionsProvider` | `VpnManager` asks the user | Stop and start of the service |
|
||||
|
||||
Update and apply run on their own, debounced. A restart never does: rebuilding the tunnel cuts every connection
|
||||
through it, so `VpnManager` only shows a notifier and the user taps its restart action. A setting that only Flutter
|
||||
reads takes no path.
|
||||
|
||||
## Update
|
||||
|
||||
`CoreManager` hands each change of `updateParamsProvider` to `SetupAction.updateConfigDebounce`. `updateConfig` then sends
|
||||
`PatchClashConfig.toUpdateParams` to Go's `updateConfig` in `core/config.go`. That function writes the fields into the
|
||||
running config and calls the runtime setter for each one, or `updateListeners` for the inbounds. Nothing is re-parsed.
|
||||
|
||||
The update path carries these fields:
|
||||
|
||||
- From `PatchClashConfig`: `mode`, `log-level`, `mixed-port`, `allow-lan`, `ipv6`, `find-process-mode`,
|
||||
`tcp-concurrent`, `unified-delay`, `external-controller`, `secret`, `geo-auto-update`, `geo-update-interval`,
|
||||
`geox-url`, and the `tun` keys of `Tun.coreJson`: `enable`, `device`, `stack`, and `auto-route`, always on.
|
||||
- From other settings: `NetworkProps.authentication`, and `AppSettingProps.checkCertificate`, sent as
|
||||
`skip-cert-verify`.
|
||||
|
||||
On desktop, `updateListeners` rebuilds the TUN from the patched `tun`. Enabling TUN may need authorization first. When
|
||||
`requestAdmin` gets it newly granted, `updateConfig` restarts the Core instead of patching it. On Android,
|
||||
`updateListeners` skips TUN, so a `tun` update reaches the VPN only through a restart.
|
||||
|
||||
To move a setting onto this path:
|
||||
|
||||
1. Add it to Dart `UpdateParams` and `toUpdateParams`.
|
||||
2. Add it to Go `UpdateParams` in `core/protocol.go` and apply it in `updateConfig`.
|
||||
3. Reset it in `PatchClashConfig.setupOnly`. If you skip this, every change also reloads the profile. The
|
||||
`PatchClashConfig.setupOnly` test in `test/models/config_test.dart` fails until its fixture changes the field and
|
||||
`setupOnly` resets it.
|
||||
4. Keep writing it in `_makeRealProfileTask`, so a full apply hands the Core the same value.
|
||||
|
||||
Prefer update whenever Go has a setter for the setting, because an apply reloads the whole config.
|
||||
|
||||
## Apply
|
||||
|
||||
`CoreManager` answers each change of `setupPatchProvider` with `applyProfileDebounce(silence: true)`. That call
|
||||
rebuilds the profile YAML and hands it to Go's `applyConfig` through `setupConfig`. The provider holds three values:
|
||||
|
||||
- `PatchClashConfig.setupOnly`: the config with every field that update carries reset to its default.
|
||||
- `NetworkProps.appendSystemDns`.
|
||||
- `NetworkProps.bypassPrivateRoute` on desktop, where the task adds the private ranges to the TUN's final
|
||||
`route-exclude-address`.
|
||||
|
||||
A change to anything else in `PatchClashConfig` therefore reaches it. Today that is the DNS and TUN overrides (`dns`,
|
||||
`dnsOverrideKeys`, `tunOverride`, `tunOverrideKeys`), `hosts`, the extra inbound ports (`port`, `socks-port`,
|
||||
`redir-port`, `tproxy-port`), the dialer options (`keep-alive-*`, `disable-keep-alive`, `interface-name`, its mode,
|
||||
`routing-mark`), `geodata-loader` and `global-ua`. A new `PatchClashConfig` field written by `_makeRealProfileTask` lands here without further work. A value
|
||||
the profile build reads from another settings object has to be added to `setupPatchProvider`.
|
||||
|
||||
An apply without `force` sends nothing when the md5 of the generated YAML has not changed. A value that only
|
||||
`SetupParams` carries never changes the YAML, so this path does not deliver it. `testUrl` is that case: Go's default
|
||||
health-check URL follows it only when some other change or a `force` apply reaches the Core.
|
||||
|
||||
Profile content does not go through `setupPatchProvider`. `setupStateProvider` reads rules, groups and scripts from the
|
||||
database once instead of watching them. Content is applied explicitly by whatever changes it:
|
||||
|
||||
- Profile editors call `autoApplyProfile` when they close.
|
||||
- Subscription updates, scripts and app-level providers call `applyProfileDebounce`.
|
||||
- A profile switch runs `fullSetup`.
|
||||
- A start applies again with `force` unless the last apply saw the same inputs (`_inputsApplied`), the `testUrl` and
|
||||
`skipCertVerify` that only `SetupParams` carries included.
|
||||
|
||||
## Restart
|
||||
|
||||
`VpnService.handleStart` builds the tunnel once from `VpnOptions` and never reads them again.
|
||||
`ServiceController.start` uses `enable` to choose between `VpnService` and `ProxyService`.
|
||||
|
||||
`vpnOptionsProvider` derives the options the service would start with now. `runningVpnOptionsProvider` records what
|
||||
the last listener start handed it. The route list is subtracted in an isolate by `vpnRouteAddressProvider`, which
|
||||
reruns only when the route lists or `bypassPrivateRoute` change, so `vpnOptionsProvider` is async: a start awaits it
|
||||
before recording, the Android Core awaits it before its first `syncState`, and the shared state keeps the last options
|
||||
while new routes compute rather than handing the service an empty list. `SetupAction` owns every start and stop: it
|
||||
sets the record on a start and clears it on a stop, on-demand SSID suspension included.
|
||||
|
||||
`VpnManager` shows the notifier whenever a change leaves `VpnOptions.effective` different from the recorded options.
|
||||
Repeated changes refresh the same notifier instead of stacking new ones. Changing a setting back to the running value
|
||||
asks for nothing. The tap runs `SetupAction.restartVpn`: a stop, then a start, and only while a run is up.
|
||||
|
||||
| `VpnOptions` field | Taken from | The service reads it |
|
||||
|------------------------|---------------------------------------------------------------|----------------------------------------|
|
||||
| `enable` | `VpnProps.enable`, off in safe mode | always |
|
||||
| `stack` | `Tun.stack` | with the VPN |
|
||||
| `ipv6`, `dnsHijacking` | `VpnProps` | with the VPN |
|
||||
| `allowBypass` | `VpnProps` | with the VPN |
|
||||
| `accessControlProps` | `VpnProps.accessControlProps` | `enable`, `mode` and that mode's list |
|
||||
| `systemProxy` | `VpnProps.systemProxy`, off under local auth or safe mode | with the VPN |
|
||||
| `port` | `PatchClashConfig.mixedPort` | with `systemProxy` |
|
||||
| `bypassDomain` | `NetworkProps.bypassDomain` | with `systemProxy` |
|
||||
| `routeAddress` | `Tun.routeAddress` and `bypassPrivateRoute` | with the VPN |
|
||||
| `mtu` | `Tun` | with the VPN |
|
||||
| `congestionController` | `Tun` | with the VPN |
|
||||
|
||||
The service never reads the access list's sort and filter fields. `effective` resets them, along with every field
|
||||
the table does not mark as read, so changing them does not ask for a restart.
|
||||
|
||||
To add a VPN option:
|
||||
|
||||
1. Add it to Dart and Kotlin `VpnOptions` and to `vpnOptionsProvider`.
|
||||
2. If the service reads it only under a condition, put that condition in `VpnOptions.effective`, which mirrors
|
||||
`ServiceController.start` and `VpnService.handleStart`.
|
||||
|
||||
The notifier stays off while nothing runs, during on-demand suspension, in safe mode, and on desktop, where
|
||||
`VpnManager` is not mounted. When Flutter reattaches to a service that is already running, it records the current
|
||||
options, because the service does not report the options it was started with.
|
||||
|
||||
## One Setting, Several Paths
|
||||
|
||||
Each path watches its own provider, so a single setting can take more than one of them:
|
||||
|
||||
- `mixedPort`: update moves the Core's listener; under the VPN system proxy, the restart notice follows.
|
||||
- TUN switch and stack: update rebuilds the desktop TUN. On Android the update reaches no TUN, and the restart notice
|
||||
carries the stack.
|
||||
- MTU, congestion control and routes: Android's rows take the restart path; on desktop they are TUN overrides, applied
|
||||
with the profile.
|
||||
- `bypassPrivateRoute`: apply on desktop, restart on Android.
|
||||
- Local authentication: update applies it to the inbounds. It also switches the VPN system proxy, which needs a
|
||||
restart.
|
||||
|
||||
## Choosing The Path
|
||||
|
||||
For a new or moved setting, answer each question. More than one answer can be yes.
|
||||
|
||||
1. Does the Android service read it when it starts? Then it belongs in `VpnOptions` and takes the restart path.
|
||||
2. Does the Core read it, and can Go change it in place? Then it belongs in `UpdateParams` and takes the update path.
|
||||
3. Does the Core read it only when it parses a config? Then write it into the generated config, and it takes the
|
||||
apply path.
|
||||
4. Does only Flutter read it? Then it takes no path.
|
||||
|
||||
Tests that hold these rules:
|
||||
|
||||
- `test/models/config_test.dart` (`PatchClashConfig.setupOnly`).
|
||||
- `test/models/core_test.dart` (`VpnOptions.effective`).
|
||||
- `test/providers/state_derived_test.dart` (the setup patch).
|
||||
- `test/manager/core_manager_test.dart` (update against apply).
|
||||
- `test/manager/vpn_manager_test.dart` (the restart notice).
|
||||
- `test/providers/setup_action_test.dart` (running options, suspension, `restartVpn`).
|
||||
+5
-12
@@ -1,24 +1,17 @@
|
||||
# Agent Skills
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Available Repo Skills
|
||||
|
||||
- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.
|
||||
- `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.
|
||||
- `provider-tests`: Riverpod provider, notifier, and state-management tests.
|
||||
- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, and user-facing interactions.
|
||||
- `core-platform`: core integration, platform managers, Go core communication, desktop/mobile behavior, and Windows helper flow.
|
||||
|
||||
## Authoring Notes
|
||||
|
||||
- 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.
|
||||
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`.
|
||||
- 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,65 +1,36 @@
|
||||
---
|
||||
name: core-platform
|
||||
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.
|
||||
description: Use when changing FlClash core integration, platform managers, Go core communication, desktop/mobile platform behavior, or Windows helper flow.
|
||||
---
|
||||
|
||||
# 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/`, build hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
|
||||
|
||||
## Read First
|
||||
## Workflow
|
||||
|
||||
- `.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, gomobile callback rules, `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/setting-changes.md`: a setting the Core or the Android VPN reads is patched live, re-applied with the
|
||||
profile, or left to a restart the user confirms; it says which, and what adding a setting to each path takes.
|
||||
- `.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.
|
||||
1. Identify which boundary owns the behavior:
|
||||
- Android lib mode: `lib/core/lib.dart`.
|
||||
- Desktop process/socket mode: `lib/core/service.dart`.
|
||||
- Shared facade: `lib/core/controller.dart` and `lib/core/interface.dart`.
|
||||
- Platform lifecycle: `lib/manager/`.
|
||||
2. Route feature code through `CoreController` and `CoreHandlerInterface`; avoid direct calls to platform implementations outside their boundary.
|
||||
3. Keep desktop and mobile paths explicit.
|
||||
4. For action-layer behavior, inspect `lib/providers/action.dart` and relevant generated providers.
|
||||
5. Add or update shared Dart tests for logic that can be isolated.
|
||||
6. Manually verify native behavior when automated coverage is not practical.
|
||||
|
||||
## Owners
|
||||
## Reference Files
|
||||
|
||||
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.
|
||||
Read `.agents/architecture.md` for the current core modes, manager stack, build hooks, local plugins, and Windows helper notes.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- 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`.
|
||||
- Keep the Windows Helper protocol and Core SHA256 validation identical across
|
||||
Flutter build modes; the Helper owns executable integrity checks.
|
||||
- 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.
|
||||
|
||||
@@ -7,43 +7,39 @@ description: Use when changing FlClash UI text, scanning for hardcoded Chinese s
|
||||
|
||||
## When To Use
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Do not use this for README translation sync or manual edits to generated localization Dart.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Scan user-facing Dart text before opening many files. `flutter_intl` reads `arb/` and writes `lib/l10n/`:
|
||||
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:
|
||||
|
||||
```bash
|
||||
rg -n "[\\p{Han}]" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'
|
||||
```
|
||||
|
||||
2. Inspect the smallest relevant call sites and nearby ARB keys.
|
||||
3. Add or update every source ARB:
|
||||
3. Inspect the smallest relevant call sites and nearby ARB keys.
|
||||
4. Add or update every source ARB:
|
||||
- `arb/intl_en.arb`
|
||||
- `arb/intl_zh_CN.arb`
|
||||
- `arb/intl_ja.arb`
|
||||
- `arb/intl_ru.arb`
|
||||
- `arb/intl_fr.arb`
|
||||
- `arb/intl_es.arb`
|
||||
- `arb/intl_ko.arb`
|
||||
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:
|
||||
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:
|
||||
|
||||
```bash
|
||||
dart run intl_utils:generate
|
||||
```
|
||||
|
||||
6. Run `flutter analyze --no-fatal-infos` and `flutter test test/l10n test/lint/dynamic_message_key_test.dart`. The
|
||||
lint test checks that every locale carries the same keys and that no `Intl.message` builds a key at runtime; labels
|
||||
for enum values go through the switches in `lib/common/l10n_labels.dart`, as the ARB section of `.agents/rules.md`
|
||||
explains.
|
||||
7. Re-run the Han-text scan for targeted strings.
|
||||
7. Verify changed Dart files with `flutter analyze` when practical.
|
||||
8. 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.
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
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.
|
||||
+17
-129
@@ -1,144 +1,32 @@
|
||||
---
|
||||
name: ui-work
|
||||
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.
|
||||
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, navigation surfaces, 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.
|
||||
|
||||
## Conventions
|
||||
## Workflow
|
||||
|
||||
- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the
|
||||
look the rest of the app has: Material You from `material_ui` as the base, with Apple's interaction conventions
|
||||
(continuous corners, sheets that snap between detents, a floating tab bar) built on Material widgets rather than
|
||||
Cupertino ones. 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).
|
||||
- A component that reads no provider, model or app string goes in `lib/ui/`; one built for an app feature goes in
|
||||
`lib/widgets/`. The UI Layer section of `.agents/architecture.md` says how a `lib/ui` component gets app state.
|
||||
- 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/ui/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.
|
||||
- Text sizes and weights come from `AppTextStyle` in `lib/ui/typography.dart`, Apple's iOS Dynamic Type scale, never a
|
||||
Material `TextTheme` slot, a bare `fontSize`, or a `fontWeight`; the Typography section of `.agents/rules.md` has the
|
||||
rule.
|
||||
- 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.
|
||||
- A sheet's size belongs to its route, never to its content or call site: a bottom sheet rests at
|
||||
`SheetProps.detents`, a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from
|
||||
the window width (`lib/ui/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`), and a form sheet
|
||||
is a card `FormSheetRoute` caps in width and height (`lib/ui/form_sheet.dart`). Do not wrap a sheet's content in a
|
||||
fixed width or height to resize one sheet.
|
||||
- A form with more than one value opens with `showFormSheet` and lays its values out in `FormRow`s, never as labeled
|
||||
fields in a `CommonDialog`; a prompt for one value stays an alert. Forms in `.agents/architecture.md` has the rule.
|
||||
- A modal scrolls only once the window and keyboard leave it no room, and then through one scroll view: `CommonDialog`
|
||||
caps its body at nothing but the space it is given, and a multi-line field in a dialog or sheet grows with its text
|
||||
(`maxLines: null`) rather than scrolling inside a line count. With the keyboard up, a bottom sheet keeps what it
|
||||
shows above the keyboard, rising past its tallest detent as far as a peek below the top (`_top` in
|
||||
`lib/ui/snap_sheet.dart`).
|
||||
- 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.
|
||||
- A page loads what it shows as it mounts, never once its route has settled; what waits out a transition is work
|
||||
that repeats, such as a live feed's refreshes (`RouteMotionHoldMixin`), and the rows past the viewport
|
||||
(`arrivalScrollCacheExtent`). Page Arrival in `.agents/architecture.md` has the rule.
|
||||
- A page that waits on its data shows nothing until all of it is ready (`NullStatusSwitcher.isLoading`), and a load
|
||||
that can run long adds its illustration as `loadingStatus`, never a skeleton screen; Page Arrival in
|
||||
`.agents/architecture.md` has the rule.
|
||||
- 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.
|
||||
- A row's secondary actions open from a context menu, never a trailing more button; a list whose rows have one
|
||||
reorders in a sort mode, any other by a long press on the row. Row Actions And Sort Mode in `.agents/architecture.md`
|
||||
has the rule.
|
||||
- Quick edit belongs only on a page that edits a whole list or section, or one isolated item; never on one that edits a
|
||||
part of something other settings also write. Quick Edit in `.agents/architecture.md` has the rule.
|
||||
- A page or sheet opens on the navigator of the context it comes from, never on the root navigator unless Where A
|
||||
Route Opens in `.agents/architecture.md` allows it.
|
||||
- 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`.
|
||||
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. Run targeted verification:
|
||||
|
||||
## Corner Radii
|
||||
|
||||
All corner radii come from `lib/ui/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 dp 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 |
|
||||
| `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, 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/ui/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`.
|
||||
- Colors of surfaces follow `.agents/surfaces.md`: fills by `SurfaceLevel`, no structural borders, and
|
||||
`primaryContainer` only on a selected item.
|
||||
|
||||
`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 card 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 in the tab view at
|
||||
`ProxyCardType.compact` 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 both 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/ui/popup.dart` does. Adding a token for one of these is how a scale grows without
|
||||
bound. A card that also stands on its own elsewhere keeps its token when nested: the proxy node cards inside a proxies
|
||||
list group stay `lg` like those in the tab view, since deriving them from the group card's `xl` left them at 14, smaller
|
||||
than every other card in the app.
|
||||
|
||||
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/ui/sidebar.dart` does with 10, between the
|
||||
chip-sized `sm` and the near-pill `md` on a 40 tall row, and `_dialogCorner` in `lib/ui/shape.dart` does with 34, the
|
||||
corner of an iOS alert. 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.
|
||||
```bash
|
||||
flutter analyze
|
||||
flutter test test/widgets/
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
# Surfaces
|
||||
|
||||
FlClash draws structure with fills and keeps color for state. A surface stands out from what it sits on by a step in
|
||||
lightness, never by a border. Color appears where something is selected, on, failing, or the one action a screen is
|
||||
for. The dashboard set this direction; every other screen follows it, and Material components keep their behavior
|
||||
but give up their tonal container fills.
|
||||
|
||||
## Levels
|
||||
|
||||
`SurfaceLevel` in `lib/ui/surface.dart` names four depths. Each surface takes the level one deeper than the one
|
||||
it sits on, which `SurfaceScope` passes down the tree.
|
||||
|
||||
| Level | Light | Dark | Holds |
|
||||
|---------|---------------------------|---------------------------|---------------------------------------------------------|
|
||||
| `page` | `surfaceContainer` | `surface` | scaffold body, sheets |
|
||||
| `card` | `surfaceBright` | `surfaceContainer` | cards, grouped runs of rows, light-mode floats |
|
||||
| `block` | `surfaceContainer` | `surfaceContainerHigh` | unselected segments, tiles and wells in a card |
|
||||
| `inset` | `surfaceContainerHighest` | `surfaceContainerHighest` | small marks inside a block: glyph wells, badges, tracks |
|
||||
|
||||
Light mode alternates gray and paper: a near-white card on a gray page, a gray block in the card. The card is tone 98,
|
||||
not pure white, and the page tone 94: pure white beside a tinted gray made the white glare and the gray look soiled. Dark mode only
|
||||
lightens, because a darker step inside a lighter surface reads as a hole rather than a layer. `inset` is the floor;
|
||||
nothing nests below it, so a design that needs a fifth level needs fewer containers instead.
|
||||
|
||||
- A widget never picks its level at the call site. `CommonCard` and the other shared surfaces read
|
||||
`context.raisedSurfaceLevel` and wrap their child in `SurfaceScope`, so the same row is white on a page and gray
|
||||
inside a card. A branch on `isInBottomSheet` or on where a widget is placed to choose its fill is the bug this
|
||||
replaces.
|
||||
- Sheets are `page` level, like a page, so a section looks the same in a sheet and in a page. In dark mode a sheet
|
||||
presents a level up, as iOS presents a sheet on its elevated backgrounds: `ColorScheme.elevated` lifts every
|
||||
surface role by the step from `page` to `card`, so the sheet's page takes the tone of the cards it covers and every
|
||||
level inside it follows. Under pure black the sheet takes the plain dark scheme instead, which is the same step up
|
||||
and keeps the seed's tint that a lift from black would lose. `ElevatedColorScheme` carries the result and the sheet
|
||||
routes apply it through `ElevatedSurfaceTheme`; a sheet opened from a sheet keeps the same lift. A scrim barely
|
||||
darkens a dark page, so a dark sheet at the page's own tone read as more of the page rather than a layer over it,
|
||||
a covered sheet's peek vanished, and on black the edge and corners went with it. Light mode keeps its levels,
|
||||
because the scrim already sets a light sheet apart.
|
||||
- What floats over content, dialogs, menus, popups, the mobile dock, toasts, takes `SurfaceLevel.floatingOf`: `card`
|
||||
in light, `block` in dark. A shadow does not show on a dark page, so a float has to come out lighter than the cards
|
||||
it covers, and its content scopes to that level so a block inside still steps away from it.
|
||||
- Shadows mark what floats, plus the dashboard cards. Cards in a scrolling list have none; a grid of proxies with a
|
||||
blurred shadow each costs raster time for no extra layering. A render object that paints a shadow itself draws it
|
||||
with `drawRSuperellipse`, as `ShapeDecoration` does: Impeller blurs a superellipse in one analytic pass, but a path
|
||||
through an offscreen Gaussian blur on every frame, which made the shadows the largest raster cost of an opening menu.
|
||||
|
||||
## The Seeded Scheme
|
||||
|
||||
`seededColorScheme` in `lib/ui/color.dart` builds every app scheme: Material's scheme for the seed and the
|
||||
`ColorStyle` picked in the theme settings, with three groups of roles replaced. Everything else stays Material,
|
||||
including `toPureBlack` applied after it.
|
||||
|
||||
The four styles each change something a reader sees at once. Material's nine variants mostly moved secondary and
|
||||
tertiary, which the app barely shows, so most of them looked alike once surfaces stayed gray.
|
||||
|
||||
| Style | Material variant | Primary | Surfaces |
|
||||
|--------------|------------------|-------------------|---------------------------------|
|
||||
| `vivid` | content | the seed | near gray |
|
||||
| `tinted` | content | the seed | the seed's hue, up to chroma 10 |
|
||||
| `soft` | tonal spot | Material's, muted | near gray |
|
||||
| `monochrome` | monochrome | black or white | gray |
|
||||
|
||||
|
||||
- **Surfaces stay near gray.** Each `surface*` role keeps Material's tone but its chroma is capped at 3, the light
|
||||
`surface` and `surfaceBright` at 1.5, and `outlineVariant` at 4. Material tints neutrals by the seed (content
|
||||
divides the seed's chroma by 8), so a yellow, green, or rose seed tinted the whole page. `tinted` is that wash
|
||||
chosen on purpose and made even: every surface takes the seed's hue at a sixth of its chroma, capped at 10, so a
|
||||
gray seed stays gray and any vivid one tints about as much.
|
||||
- **`primaryContainer` comes from the primary palette.** It is primary's hue at tone 90 in light and 30 in dark,
|
||||
with `onPrimaryContainer` at 30 and 90, and the pair's chroma is capped at 48. Content and fidelity otherwise put it
|
||||
at the seed's own tone: a yellow seed filled it with pure yellow, a red seed with something that read as an error,
|
||||
and dark mode with a light block under light text. The cap matters for light, chromatic hues: yellow at tone 90 is
|
||||
still close to full strength.
|
||||
- **`vivid` and `tinted` keep the seed as `primary`**, as an Apple accent color does: the color picked in the theme
|
||||
settings is the color of every check, switch, and filled button. Light holds the seed's tone between 35 and 60, so
|
||||
white text on it keeps about 3:1 and a near-black seed still reads as a color; dark lifts it 3 tones, as iOS lifts
|
||||
its system colors, within 50 to 75, and puts dark text on it above 60. Material sets primary at tone 40 and 80 for
|
||||
any seed, which turned iOS blue into navy in light and a pastel in dark. `soft` and `monochrome` keep Material's
|
||||
primary, since moving away from the seed is what they are for.
|
||||
|
||||
## State
|
||||
|
||||
Color marks the one item that differs, never the structure around it.
|
||||
|
||||
- **Selected**, one of several rows, cards, or tiles: the item keeps its level's fill and a primary mark says which,
|
||||
as iOS checks the option picked from a list. A row in a grouped run takes `SelectionCheck` at its edge; a card that
|
||||
stands on its own, a profile or a proxy, and a tile take the filled `SelectionBadge`, which holds its own beside a
|
||||
40 dp icon well where the bare check read as small and thin (`lib/ui/selection_mark.dart`). `CommonCard.isSelected` only reports the state to
|
||||
accessibility. Fills were tried and rejected: a wash of primary landed on a gray that did not read as a choice, and
|
||||
`primaryContainer` read as a warning under rose seeds in light and as a heavy slab in dark, muddied the translucent
|
||||
marks on it, and cut a grouped run in two.
|
||||
- **Multi-select** in edit mode: the circle checkbox is the mark; the row stays at its level.
|
||||
- **Proxy groups** have two states in URLTest and Fallback: the check badge marks the proxy traffic goes through, in
|
||||
every group type, and a pin badge (`SelectionBadge.preferred`) marks the preferred proxy (mihomo's `fixed`): filled
|
||||
like the check while the group uses it, tonal while the group has fallen back to another proxy, which carries the
|
||||
check.
|
||||
- **Chosen segment** in a connected button group, a mode switch, or a run of compact choice chips
|
||||
(`CommonCard.fillsSelection`): solid `primary` with `onPrimary`, as the dashboard outbound mode does. Only one
|
||||
segment per group.
|
||||
- **Error** on a row or card: `errorContainer` with `onErrorContainer`, no border.
|
||||
- **Status** values (delay, availability, success, warning): the color goes on text, a glyph, or a small chip, never
|
||||
on a whole card. `success` and `warning` come from fixed green and orange palettes that ignore the seed, like
|
||||
Material's `error`; harmonizing pulled orange toward red under rose seeds until warning and error matched. Text takes
|
||||
tone 40 in light and 80 in dark, a chip takes `successContainer`/`warningContainer` (90/30) with the `on` role
|
||||
(30/90). A delay under 600 ms is success, above it warning, a failure error: `ColorScheme.delayColor`.
|
||||
- **Destructive** action in an alert: `ColorScheme.destructive`, iOS 27's systemRed in both modes, not `error`. A
|
||||
menu's `danger` item still takes `error`.
|
||||
- **Data series** on one chart: the first is primary, the second primary at 45% over the card
|
||||
(`DashboardColors.series2`), so they differ by lightness under any seed. Tertiary is not a series color.
|
||||
- **Text**: titles and values `onSurface`, labels and secondary lines `onSurfaceVariant`. `outline` is not a text
|
||||
color, and a value is never tinted primary.
|
||||
- **Marks** that only identify, a country badge, a group or service icon, a source avatar, sit on a neutral well with
|
||||
`onSurfaceVariant`.
|
||||
- `primaryContainer`, `secondaryContainer`, and `tertiaryContainer` are neither surface nor selection fills. Keep
|
||||
them out of cards, rows, marks, and navigation indicators.
|
||||
|
||||
## Borders That Stay
|
||||
|
||||
- **Focus**: a 2 dp primary stroke while a surface has keyboard or remote focus. TV navigation depends on it, and a
|
||||
fill change alone is too faint to follow from a couch. A card, a grouped row above all, rings only focus a key moved
|
||||
there (`FocusMovedByKey`), or any focus while a remote drives the app: a tap or a click puts focus where the reader
|
||||
is already looking, a field's caret already marks it, and the ring then read as a selection box around the row.
|
||||
- **Inputs**: a form's field is a bare `FormRowTextField` at the end of a grouped `FormRow` whose title labels it, as
|
||||
iOS and macOS set one, with no stroke or fill of its own. A field among filled controls takes their fill, one level
|
||||
above its surface, through `FilledInputTheme`. An alert's fields, because they sit over its pill buttons, as
|
||||
UIAlertController sets them: as wide as the buttons, in their fill, with their text in line with the title and the
|
||||
label as the placeholder (`CommonDialog.hasFields`); a field under a filled block of text in an alert takes the
|
||||
block's width and corner instead. The custom color's hex field, because its card holds only fills, the color
|
||||
previews, where an outline was the one stroked thing. Search fields are filled pills. A sheet's docked search,
|
||||
because the list scrolls under it: it floats like the dock, with the floating fill and the dock's shadow, or the
|
||||
dock's glass. The editor's find bar, because its fields sit in a row of filled pill buttons: they take the buttons'
|
||||
fill, one level above the bar. All of these stroke only while focused. Any other field, one standing alone on a page,
|
||||
is outlined, with the floating label notched into the stroke: `ThemeData.withAppShapes` gives every input
|
||||
`AppShape.input`, so call sites leave `border` unset. A lone field is never filled: here a fill means structure, so
|
||||
one read as one more card or row, and in light mode the hover overlay brought it down to the page's own tone. No
|
||||
field counts its characters, as none does on iOS or macOS; a length limit holds through its formatter. Material
|
||||
strokes a filled field's whole outline in `onSurfaceVariant`, which ringed the pill brighter than anything else in a
|
||||
dark sheet.
|
||||
- **Window chrome**: the desktop sidebar's inner edge and the title bar's bottom edge draw a divider where they meet
|
||||
the content. The chrome fill alone left the edge to a four-tone step, which read as a smudge rather than a boundary.
|
||||
|
||||
Nothing else draws an outline. Rows in a grouped run are separated by the hairline from `lib/ui/list_row_separator.dart`, which divides
|
||||
one surface and does not outline it. Record lists, requests, connections, logs, and DNS queries, are not grouped: they
|
||||
run to thousands of tall rows, and one card that long reads as a slab rather than a group. A stacked row of three or
|
||||
four lines is already its own block, so each is a card of its own, `RecordListItem`: the raised fill at `xl`, 16 dp
|
||||
from the edges and 8 dp from the next, as the profile cards sit. Every card takes the same fill, so the warning and
|
||||
error tints on a label still stand out, where a fill on every other row competed with them; rows on the page split by
|
||||
a full-width divider were the one structural line left on screens otherwise drawn with fills. Laid out as a table,
|
||||
where a row is one line read across many columns, they drop the gap and give every other row the raised fill, inset
|
||||
and rounded like the column header, so the eye keeps its row; the dashboard's table does the same.
|
||||
|
||||
Every divider takes its color from `ThemeData.withAppSurfaces`: `onSurface` at 14%, never `outlineVariant`. A
|
||||
translucent line keeps the same contrast on the page, a card, or the chrome, in both modes; `outlineVariant` stood out
|
||||
twice as much on the dark page as on the light one and carried the seed's tint.
|
||||
|
||||
## Navigation
|
||||
|
||||
- The desktop sidebar and title bar are window chrome, `ColorScheme.chromeSurface`: `surfaceContainerHighest` in
|
||||
light and `surfaceContainerLow` in dark, four tones from the page either way, with a divider along the edge that
|
||||
meets the content. At `page` level the sidebar merged with the content. In dark the chrome stays below `card`, so
|
||||
cards still stand out from it. The pane the sidebar opens over the content takes the same fill and edge, plus a
|
||||
shadow.
|
||||
The selected destination keeps its neutral fill and primary bar, which already marks it with color.
|
||||
- The mobile dock and the docked navigation bar are floats; their indicator is a neutral platter one level above
|
||||
them, and the selected destination's content takes primary, as iOS's tab bar marks its selection.
|
||||
- With Liquid Glass on (Theme settings), the dock, the buttons of a page's bar, a sheet's docked search, popup
|
||||
menus, and alerts are glass from `lib/ui/liquid_glass.dart` instead of a fill, and the dock's platter takes the
|
||||
secondary hue and chroma at tone 90, or at tone 4 in dark, which sinks it below the bar as iOS 27 does.
|
||||
- The proxies page's group tabs are compact choice chips, the current group in the chosen segment's solid `primary`.
|
||||
Once they overflow, a tonal button at their end opens every group as a popup menu, the current one checked and
|
||||
scrolled into view. A profile can carry dozens of groups, so the menu stops at seven rows and half of the next.
|
||||
|
||||
## Where It Lives
|
||||
|
||||
- `lib/ui/surface.dart`: `SurfaceLevel`, `SurfaceScope`, `context.surfaceLevel`, `context.raisedSurfaceLevel`,
|
||||
`ColorScheme.chromeSurface`, and `ThemeData.withAppSurfaces`, applied next to `withAppShapes` wherever the app
|
||||
builds its theme.
|
||||
- `lib/ui/color.dart`: `seededColorScheme`, the status palettes, and `delayColor`.
|
||||
- `lib/views/dashboard/style.dart`: `DashboardColors` maps `page`, `card`, `tone`, and `tone2` onto the levels and
|
||||
adds the dashboard's switch, series, and status colors.
|
||||
|
||||
## Checking A Change
|
||||
|
||||
Render the touched screens in light and dark, on a phone width and a desktop width, before and after. A throwaway
|
||||
widget test does it: pump `HomePage` or the view inside a `RepaintBoundary`, load Roboto with `FontLoader` so text
|
||||
gets real glyphs, and write `RenderRepaintBoundary.toImage` to a PNG outside the repository. Look for a surface that
|
||||
matches the one it sits on, which means it skipped `SurfaceScope`, and for a tonal container fill that survived on a
|
||||
card or row.
|
||||
@@ -1,5 +0,0 @@
|
||||
# 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,17 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PROJECT_DIR:-.}/tool/hooks/comment_density.sh\"",
|
||||
"timeout": 15,
|
||||
"statusMessage": "Checking comment density"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/core-platform
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/localization
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/pre-commit-quality-review
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/ui-work
|
||||
@@ -1,8 +0,0 @@
|
||||
[[hooks.PostToolUse]]
|
||||
matcher = "apply_patch|Bash"
|
||||
|
||||
[[hooks.PostToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "bash tool/hooks/comment_density.sh"
|
||||
timeout = 15
|
||||
statusMessage = "Checking comment density"
|
||||
@@ -1,2 +0,0 @@
|
||||
# freezed emits lines of trailing spaces, and generated code is never edited by hand.
|
||||
lib/**/generated/** -whitespace
|
||||
@@ -23,11 +23,9 @@ cask "flclash" do
|
||||
|
||||
app "FlClash.app"
|
||||
|
||||
postflight_steps do
|
||||
run "/usr/bin/xattr",
|
||||
args: ["-rd", "com.apple.quarantine", "{{appdir}}/FlClash.app"],
|
||||
writable_paths: ["FlClash.app"],
|
||||
writable_base: :appdir
|
||||
postflight do
|
||||
system_command "xattr",
|
||||
args: ["-rd", "com.apple.quarantine", "#{appdir}/FlClash.app"]
|
||||
end
|
||||
|
||||
uninstall quit: "com.follow.clash"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
previous_tag="${1:-}"
|
||||
output="${2:-release.md}"
|
||||
|
||||
: > "$output"
|
||||
|
||||
append_range() {
|
||||
local range="$1"
|
||||
|
||||
git log --no-merges --pretty=format:'%B' "$range" |
|
||||
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$output"
|
||||
}
|
||||
|
||||
current_tag=""
|
||||
while IFS= read -r next_tag; do
|
||||
if [[ -n "$current_tag" ]]; then
|
||||
[[ "$current_tag" == "$previous_tag" ]] && break
|
||||
append_range "$next_tag..$current_tag"
|
||||
fi
|
||||
current_tag="$next_tag"
|
||||
done < <(git tag --merged HEAD --sort=-creatordate)
|
||||
|
||||
if [[ -n "$current_tag" && "$current_tag" != "$previous_tag" ]]; then
|
||||
append_range "$current_tag"
|
||||
fi
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
generator="$script_dir/generate_release_notes.sh"
|
||||
temp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$temp_dir"' EXIT
|
||||
|
||||
repo="$temp_dir/repo"
|
||||
git init --quiet --initial-branch=main "$repo"
|
||||
git -C "$repo" config user.email "release-notes-test@example.com"
|
||||
git -C "$repo" config user.name "Release notes test"
|
||||
|
||||
commit() {
|
||||
local message="$1"
|
||||
local date="$2"
|
||||
|
||||
GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \
|
||||
git -C "$repo" commit --allow-empty --quiet --message "$message"
|
||||
}
|
||||
|
||||
commit "Initial release" "2025-01-01T00:00:00Z"
|
||||
git -C "$repo" tag v1.0.0
|
||||
|
||||
commit "First release change" "2025-01-02T00:00:00Z"
|
||||
commit "Update changelog" "2025-01-03T00:00:00Z"
|
||||
git -C "$repo" tag v1.1.0
|
||||
|
||||
git -C "$repo" branch feature
|
||||
git -C "$repo" checkout --quiet feature
|
||||
commit $'Feature branch change\n\nFeature detail' "2025-01-04T00:00:00Z"
|
||||
git -C "$repo" checkout --quiet main
|
||||
GIT_AUTHOR_DATE="2025-01-05T00:00:00Z" \
|
||||
GIT_COMMITTER_DATE="2025-01-05T00:00:00Z" \
|
||||
git -C "$repo" merge --no-ff --quiet --message "Merge feature" feature
|
||||
git -C "$repo" tag v1.2.0
|
||||
|
||||
expected="$temp_dir/expected.md"
|
||||
actual="$temp_dir/actual.md"
|
||||
printf '%s\n' \
|
||||
"- Feature branch change" \
|
||||
"" \
|
||||
"- Feature detail" \
|
||||
"" \
|
||||
"- First release change" \
|
||||
"" > "$expected"
|
||||
|
||||
(
|
||||
cd "$repo"
|
||||
bash "$generator" v1.0.0 "$actual"
|
||||
)
|
||||
diff -u "$expected" "$actual"
|
||||
|
||||
printf '%s\n' "stale content" > "$actual"
|
||||
(
|
||||
cd "$repo"
|
||||
bash "$generator" v1.2.0 "$actual"
|
||||
)
|
||||
[[ ! -s "$actual" ]]
|
||||
|
||||
(
|
||||
cd "$repo"
|
||||
bash "$generator" "" "$actual"
|
||||
)
|
||||
grep -q -- "- Initial release" "$actual"
|
||||
+117
-329
@@ -2,139 +2,26 @@ name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
pull_request:
|
||||
env:
|
||||
IS_STABLE: ${{ !contains(github.ref, '-') }}
|
||||
# See CI Caches in .agents/commands.md.
|
||||
BRANCH_CACHE: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
FLUTTER_VERSION: '3.47.6'
|
||||
GO_VERSION: '1.27.1'
|
||||
NDK_VERSION: r28c
|
||||
FLUTTER_VERSION: '3.44.4'
|
||||
|
||||
jobs:
|
||||
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
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Test git hooks
|
||||
run: bash tool/hooks/test.sh
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: plugins/rust_api/rust
|
||||
cache-directories: .dart_tool/hooks_runner
|
||||
save-if: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- 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: dart run tool/test.dart -- --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: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Validate local Flutter packages
|
||||
run: bash tool/check_plugins.sh
|
||||
|
||||
- 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: (github.event_name == 'workflow_dispatch' || !github.event.repository.private) && !(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
|
||||
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: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: |
|
||||
core/go.sum
|
||||
plugins/setup/setup_hooks/lib/src/go_builder.dart
|
||||
|
||||
- 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: Test release notes generation
|
||||
shell: bash
|
||||
run: bash .github/scripts/generate_release_notes_test.sh
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
@@ -143,45 +30,49 @@ jobs:
|
||||
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
|
||||
- name: Analyze
|
||||
run: flutter analyze --no-fatal-infos
|
||||
|
||||
go:
|
||||
name: Go core
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Run tests
|
||||
run: flutter test --reporter expanded
|
||||
|
||||
- name: Validate local Flutter packages
|
||||
shell: bash
|
||||
run: |
|
||||
for package in plugins/proxy plugins/wifi_ssid plugins/window_ext plugins/setup; do
|
||||
(
|
||||
cd "$package"
|
||||
flutter pub get
|
||||
flutter analyze --no-fatal-infos
|
||||
)
|
||||
done
|
||||
|
||||
- name: Run local plugin tests
|
||||
shell: bash
|
||||
run: |
|
||||
(
|
||||
cd plugins/proxy
|
||||
flutter test --reporter expanded
|
||||
)
|
||||
(
|
||||
cd plugins/wifi_ssid
|
||||
flutter test --reporter expanded
|
||||
)
|
||||
|
||||
- name: Validate setup build tool
|
||||
working-directory: plugins/setup/buildkit/build_tool
|
||||
run: |
|
||||
dart pub get
|
||||
dart analyze
|
||||
dart test
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
go-version: '1.26.4'
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Validate Go core wrapper
|
||||
@@ -189,133 +80,23 @@ jobs:
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
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 ./cmd ./mobile ./platform ./tun)" || {
|
||||
gofmt -l ./*.go ./cmd ./mobile ./platform ./tun >&2
|
||||
exit 1
|
||||
}
|
||||
go test -count=1 .
|
||||
go vet . ./cmd/...
|
||||
GOOS=linux go vet ./platform
|
||||
go test .
|
||||
go vet .
|
||||
|
||||
- name: Validate the Android core library
|
||||
working-directory: core
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
tags="$(sed -n 's/^tags: *//p' ../build_config.yaml)"
|
||||
test -n "$tags" || { echo 'Could not read tags from build_config.yaml' >&2; exit 1; }
|
||||
GOOS=android GOARCH=arm64 go vet "-tags=$tags" . ./mobile ./tun ./platform
|
||||
|
||||
android:
|
||||
name: Android unit tests
|
||||
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: ${{ env.BRANCH_CACHE }}
|
||||
cache-dependency-path: |
|
||||
core/go.sum
|
||||
android/core/build.gradle.kts
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Setup NDK
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
link-to-sdk: true
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
- 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
|
||||
# No JVM unit test loads the Core or the Flutter build, but `:core` compiles against
|
||||
# the gomobile AAR, so one ABI of it is built.
|
||||
./gradlew :common:testDebugUnitTest :service:testDebugUnitTest :app:testDebugUnitTest \
|
||||
-Ptarget-platform=android-arm64 \
|
||||
-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
|
||||
save-if: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Check format
|
||||
- name: Validate Rust components
|
||||
run: |
|
||||
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
cargo fmt --manifest-path plugins/rust_api/rust/Cargo.toml -- --check
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cargo test --manifest-path services/helper/Cargo.toml
|
||||
cargo fmt --manifest-path plugins/rust_api/rust/Cargo.toml -- --check
|
||||
cargo test --manifest-path plugins/rust_api/rust/Cargo.toml
|
||||
|
||||
windows-helper-test:
|
||||
name: Windows helper test
|
||||
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
|
||||
save-if: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Check format
|
||||
run: cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
|
||||
@@ -323,8 +104,8 @@ jobs:
|
||||
run: cargo test --manifest-path services/helper/Cargo.toml --features windows-service
|
||||
|
||||
build:
|
||||
# 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')
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [ test, windows-helper-test ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -346,11 +127,9 @@ jobs:
|
||||
- platform: windows
|
||||
os: windows-11-arm
|
||||
arch: arm64
|
||||
flutter-channel: master
|
||||
- platform: linux
|
||||
os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
flutter-channel: master
|
||||
|
||||
steps:
|
||||
- name: Setup rust
|
||||
@@ -371,6 +150,7 @@ jobs:
|
||||
if: startsWith(matrix.platform,'android')
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
|
||||
echo "${{ secrets.SERVICE_JSON }}" | base64 --decode > android/app/google-services.json
|
||||
{
|
||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}"
|
||||
echo "storePassword=${{ secrets.STORE_PASSWORD }}"
|
||||
@@ -380,26 +160,20 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Setup Gradle
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
go-version: '1.26.4'
|
||||
cache-dependency-path: |
|
||||
core/go.sum
|
||||
|
||||
- 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:
|
||||
channel: ${{ matrix.flutter-channel || 'stable' }}
|
||||
channel: master
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
cache: true
|
||||
|
||||
- name: Setup Xcode
|
||||
if: startsWith(matrix.platform,'macos')
|
||||
@@ -410,9 +184,8 @@ jobs:
|
||||
- name: Setup NDK
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: build-ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
ndk-version: r28c
|
||||
add-to-path: true
|
||||
link-to-sdk: true
|
||||
|
||||
@@ -421,26 +194,7 @@ 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
|
||||
|
||||
# The core embeds this bundle and FlClashHttpOverrides.trustCoreRoots loads it for Dart.
|
||||
# Pinned by hash, so a new bundle from https://curl.se/docs/caextract.html is a reviewed bump.
|
||||
- name: Fetch CA certificates
|
||||
shell: bash
|
||||
env:
|
||||
CA_BUNDLE: cacert-2026-09-25.pem
|
||||
CA_BUNDLE_SHA256: a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505
|
||||
run: |
|
||||
bundle=core/Clash.Meta/component/ca/ca-certificates.crt
|
||||
curl -fsSL --retry 3 -o "$bundle" "https://curl.se/ca/$CA_BUNDLE"
|
||||
echo "$CA_BUNDLE_SHA256 $bundle" | sha256sum -c -
|
||||
|
||||
- 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
|
||||
@@ -450,10 +204,65 @@ 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
|
||||
needs: [ build, dart, plugins, go, android, rust, windows-helper-test ]
|
||||
needs: [ build ]
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
telegram-bot-api:
|
||||
@@ -476,35 +285,14 @@ jobs:
|
||||
pattern: artifact-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Skip native build hooks
|
||||
run: |
|
||||
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false' pubspec.yaml
|
||||
|
||||
- name: Generate release notes
|
||||
- name: Generate release.md
|
||||
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
|
||||
|
||||
pre_tag=$(
|
||||
curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" |
|
||||
sed -nE 's/.*"tag_name": "([^"]+)".*/\1/p'
|
||||
)
|
||||
bash .github/scripts/generate_release_notes.sh "$pre_tag" release.md
|
||||
- name: Push to telegram
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
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
|
||||
+1
-3
@@ -43,7 +43,6 @@ app.*.map.json
|
||||
|
||||
# Claude Code - ignore user-specific settings only
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Documentation (generated/planning artifacts)
|
||||
docs/
|
||||
@@ -57,7 +56,6 @@ docs/
|
||||
/android/**/build
|
||||
/android/common/**/.**/
|
||||
/android/common/local.*
|
||||
# Left in place by builds from before the gomobile Core.
|
||||
/android/core/**/includes/
|
||||
/android/core/**/cmake-build-*/
|
||||
/android/core/**/jniLibs/
|
||||
@@ -70,8 +68,8 @@ docs/
|
||||
/macos/build/
|
||||
/env.json
|
||||
/core_sha256.json
|
||||
/core/*.exe
|
||||
/plugins/rust_api/.claude/
|
||||
/issue-triage/
|
||||
devtools_options.yaml
|
||||
|
||||
# FVM Version Cache
|
||||
|
||||
+7
-13
@@ -6,21 +6,15 @@ repos:
|
||||
entry: dart format
|
||||
language: system
|
||||
files: \.dart$
|
||||
stages: [pre-commit]
|
||||
- id: comment-density
|
||||
name: comment density
|
||||
entry: tool/hooks/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)
|
||||
name: flutter analyze
|
||||
entry: flutter analyze --no-fatal-infos
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-push]
|
||||
- id: commit-msg-format
|
||||
name: conventional commit message
|
||||
entry: tool/hooks/commit_msg.sh
|
||||
files: \.dart$
|
||||
- id: flutter-test
|
||||
name: flutter test
|
||||
entry: flutter test --reporter expanded
|
||||
language: system
|
||||
stages: [commit-msg]
|
||||
pass_filenames: false
|
||||
files: \.dart$
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<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>
|
||||
@@ -1,24 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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",
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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,
|
||||
},
|
||||
]
|
||||
@@ -15,63 +15,21 @@ Read these only when the task touches their area:
|
||||
|
||||
- [.agents/architecture.md](.agents/architecture.md): core integration, providers, database, managers, build system, and
|
||||
local plugins.
|
||||
- [.agents/setting-changes.md](.agents/setting-changes.md): whether a changed setting is patched live, re-applies the
|
||||
profile, or asks for an Android VPN restart.
|
||||
- [.agents/surfaces.md](.agents/surfaces.md): fill levels, selection and status colors, and the few borders that
|
||||
remain, for any change to how a surface is colored.
|
||||
- [.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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Preserve lifecycle ownership: desktop Core process convergence belongs to `lib/core/desktop/`; Android service intent
|
||||
arbitration belongs to `ServiceState`. UI/provider code may request a transition but must not become a second source of
|
||||
truth.
|
||||
- Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while
|
||||
native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded
|
||||
requests.
|
||||
- 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/ui/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).
|
||||
- Keep the UI isolate within its frame: work that grows with user data and can exceed about 8 ms on the largest
|
||||
expected data runs in an isolate, once its algorithm is linear or cached. See the UI Isolate Work 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
|
||||
- Follow `analysis_options.yaml`, especially single quotes, trailing commas, `child:` last, no `print()`, const/final
|
||||
preferences, and declared return types.
|
||||
- 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.
|
||||
- For CI parity, verify with `flutter pub get`, `flutter analyze --no-fatal-infos`, and
|
||||
`flutter test --reporter expanded` when practical.
|
||||
|
||||
## Repo Skills
|
||||
|
||||
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.
|
||||
Use repo skills from `.agents/skills/` when a task matches their descriptions. Current skills cover localization,
|
||||
provider tests, UI work, and core/platform changes.
|
||||
|
||||
+405
-182
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
# 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,12 +1,35 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: help submodules
|
||||
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
|
||||
|
||||
help:
|
||||
@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.'
|
||||
@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'
|
||||
|
||||
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,138 +1,139 @@
|
||||
<div align="center">
|
||||
<div>
|
||||
|
||||
<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)
|
||||
[**简体中文**](README_zh_CN.md)
|
||||
|
||||
</div>
|
||||
|
||||
<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>
|
||||
## 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>
|
||||
|
||||
## Features
|
||||
|
||||
- **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 Apple-style interaction**: dynamic color, light and dark themes, continuous corners,
|
||||
sheets that snap between heights, 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.
|
||||
✈️ 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
|
||||
```
|
||||
|
||||
## Download
|
||||
|
||||
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.
|
||||
<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>
|
||||
|
||||
| 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**
|
||||
### Homebrew
|
||||
|
||||
```bash
|
||||
brew tap chen08209/tap
|
||||
brew install --cask flclash
|
||||
```
|
||||
|
||||
**Linux tray icon**
|
||||
## Build
|
||||
|
||||
The `.deb` package installs its own dependencies. With the AppImage or the `.rpm`, install the AyatanaAppIndicator
|
||||
library so the tray icon can show:
|
||||
1. Update submodules
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-1 # Debian and Ubuntu
|
||||
sudo dnf install libayatana-appindicator-gtk3 # Fedora
|
||||
```
|
||||
2. Install `Flutter` and `Golang` environment
|
||||
|
||||
## Usage
|
||||
3. Build Application
|
||||
|
||||
**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.
|
||||
- android
|
||||
|
||||
```text
|
||||
clash://install-config?url=<URL-encoded subscription link>
|
||||
```
|
||||
1. Install `Android SDK`, `Android NDK`
|
||||
|
||||
**Automate on Android.** Tasker, MacroDroid and similar apps can start, stop or toggle the proxy by starting an
|
||||
activity with one of these actions:
|
||||
2. Set `ANDROID_NDK` environment variable
|
||||
|
||||
```text
|
||||
com.follow.clash.action.START
|
||||
com.follow.clash.action.STOP
|
||||
com.follow.clash.action.TOGGLE
|
||||
```
|
||||
3. Run build script
|
||||
|
||||
From a computer, the same works over adb:
|
||||
```bash
|
||||
dart setup.dart android
|
||||
```
|
||||
|
||||
```bash
|
||||
adb shell am start -a com.follow.clash.action.TOGGLE
|
||||
```
|
||||
- windows
|
||||
|
||||
## Build from source
|
||||
1. Requires a Windows client
|
||||
|
||||
You need [Flutter](https://docs.flutter.dev/get-started/install) 3.47 (release builds use 3.47.6),
|
||||
[Go](https://go.dev/dl/) 1.27 and [Rust](https://rustup.rs/) installed through rustup. Each platform builds on its own
|
||||
host, except Android, which builds anywhere.
|
||||
2. Install `GCC`, `Inno Setup`
|
||||
|
||||
```bash
|
||||
git clone --recursive https://github.com/chen08209/FlClash.git
|
||||
cd FlClash
|
||||
flutter pub get
|
||||
dart setup.dart android # or windows, macos, linux
|
||||
```
|
||||
3. Run build script
|
||||
|
||||
Packages land in `dist/`. The Go core and the Rust libraries are compiled as part of the Flutter build.
|
||||
```bash
|
||||
dart setup.dart windows
|
||||
```
|
||||
|
||||
| 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`. |
|
||||
- linux
|
||||
|
||||
Run `dart setup.dart --help` for the remaining options, such as `--targets` to build only some package formats.
|
||||
1. Requires a Linux client
|
||||
|
||||
## Support
|
||||
2. Dependencies are auto-installed by setup script, or manually:
|
||||
```bash
|
||||
sudo apt-get install -y libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
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).
|
||||
3. Run build script
|
||||
|
||||
<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>
|
||||
```bash
|
||||
dart setup.dart linux
|
||||
```
|
||||
|
||||
## License
|
||||
- macOS
|
||||
|
||||
FlClash is released under the [GPL-3.0 license](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>
|
||||
|
||||
+105
-96
@@ -1,130 +1,139 @@
|
||||
<div align="center">
|
||||
<div>
|
||||
|
||||
<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) · [从源码构建](#从源码构建)
|
||||
[**English**](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
<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>
|
||||
## 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>
|
||||
|
||||
## 功能
|
||||
|
||||
- **一个应用覆盖 Android、Windows、macOS 和 Linux**,桌面端同时提供 x64 与 ARM64 版本。
|
||||
- **mihomo(Clash.Meta)内核**:规则分流、代理组、延迟测试、系统代理与 TUN 模式。
|
||||
- **配置管理**:通过订阅链接或文件导入配置,内置编辑器,支持覆写脚本以及自定义规则、代理和代理组。
|
||||
- **实时查看**连接、请求、DNS 查询和日志。
|
||||
- **Material You 设计,融合 Apple 式交互**:支持动态取色、浅色与深色主题,连续曲率圆角,弹出面板可在几档高度间吸附,布局随屏幕从手机到桌面自适应。
|
||||
- **备份与恢复**:通过 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>
|
||||
on Mobile:
|
||||
<p style="text-align: center;">
|
||||
<img alt="mobile" src="snapshots/mobile.gif">
|
||||
</p>
|
||||
|
||||
**Homebrew**
|
||||
## 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
|
||||
|
||||
```bash
|
||||
brew tap chen08209/tap
|
||||
brew install --cask flclash
|
||||
```
|
||||
|
||||
**Linux 托盘图标**
|
||||
## Build
|
||||
|
||||
`.deb` 安装包会自动安装所需依赖。使用 AppImage 或 `.rpm` 时,需要先安装 AyatanaAppIndicator 库,托盘图标才能显示:
|
||||
1. 更新 submodules
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-1 # Debian 与 Ubuntu
|
||||
sudo dnf install libayatana-appindicator-gtk3 # Fedora
|
||||
```
|
||||
2. 安装 `Flutter` 以及 `Golang` 环境
|
||||
|
||||
## 使用
|
||||
3. 构建应用
|
||||
|
||||
**通过链接导入配置。** 打开下面格式的链接,即可把订阅导入 FlClash。`clashmeta://` 和 `flclash://` 开头的链接同样可用。
|
||||
- android
|
||||
|
||||
```text
|
||||
clash://install-config?url=<经过 URL 编码的订阅链接>
|
||||
```
|
||||
1. 安装 `Android SDK` , `Android NDK`
|
||||
|
||||
**Android 自动化。** Tasker、MacroDroid 等应用可以用下面的 Action 启动 Activity,从而启动、停止或切换代理:
|
||||
2. 设置 `ANDROID_NDK` 环境变量
|
||||
|
||||
```text
|
||||
com.follow.clash.action.START
|
||||
com.follow.clash.action.STOP
|
||||
com.follow.clash.action.TOGGLE
|
||||
```
|
||||
3. 运行构建脚本
|
||||
|
||||
在电脑上也可以通过 adb 触发:
|
||||
```bash
|
||||
dart setup.dart android
|
||||
```
|
||||
|
||||
```bash
|
||||
adb shell am start -a com.follow.clash.action.TOGGLE
|
||||
```
|
||||
- windows
|
||||
|
||||
## 从源码构建
|
||||
1. 你需要一个windows客户端
|
||||
|
||||
需要 [Flutter](https://docs.flutter.dev/get-started/install) 3.47(正式版构建使用 3.47.6)、[Go](https://go.dev/dl/) 1.27,
|
||||
以及通过 rustup 安装的 [Rust](https://rustup.rs/)。桌面端需要在对应系统上构建,Android 在任意系统上都能构建。
|
||||
2. 安装 `GCC`,`Inno Setup`
|
||||
|
||||
```bash
|
||||
git clone --recursive https://github.com/chen08209/FlClash.git
|
||||
cd FlClash
|
||||
flutter pub get
|
||||
dart setup.dart android # 或 windows、macos、linux
|
||||
```
|
||||
3. 运行构建脚本
|
||||
|
||||
安装包输出到 `dist/`。Go 内核和 Rust 库会在 Flutter 构建过程中一并编译。
|
||||
```bash
|
||||
dart setup.dart windows
|
||||
```
|
||||
|
||||
| 平台 | 额外依赖 |
|
||||
| --- | --- |
|
||||
| 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`。 |
|
||||
- linux
|
||||
|
||||
其余选项可以运行 `dart setup.dart --help` 查看,例如用 `--targets` 只构建部分安装包格式。
|
||||
1. 你需要一个linux客户端
|
||||
|
||||
## 支持
|
||||
2. 依赖会由 setup 脚本自动安装,也可以手动安装:
|
||||
```bash
|
||||
sudo apt-get install -y libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
给仓库点一个 Star 是支持项目最简单的方式。问题讨论和更新通知请关注 [Telegram 频道](https://t.me/FlClash),
|
||||
Bug 与功能建议请提交到 [GitHub Issues](https://github.com/chen08209/FlClash/issues)。
|
||||
3. 运行构建脚本
|
||||
|
||||
<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>
|
||||
```bash
|
||||
dart setup.dart linux
|
||||
```
|
||||
|
||||
## 许可证
|
||||
- macOS
|
||||
|
||||
FlClash 以 [GPL-3.0 协议](LICENSE)发布。
|
||||
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>
|
||||
|
||||
+35
-8
@@ -1,16 +1,43 @@
|
||||
include: lint_options.yaml
|
||||
|
||||
include: package:flutter_lints/flutter.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
|
||||
@@ -4,6 +4,8 @@ import java.util.Properties
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
id("com.google.gms.google-services")
|
||||
id("com.google.firebase.crashlytics")
|
||||
}
|
||||
|
||||
val localProperties = Properties().apply {
|
||||
@@ -79,11 +81,6 @@ android {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
// Unit tests live under android/tests/ instead of each module's src/test.
|
||||
getByName("test").java.setSrcDirs(listOf("../tests/app"))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -105,6 +102,7 @@ dependencies {
|
||||
implementation(libs.smali.dexlib2) {
|
||||
exclude(group = "com.google.guava", module = "guava")
|
||||
}
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.crashlytics.ndk)
|
||||
implementation(libs.firebase.analytics)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "000000000000",
|
||||
"project_id": "dev"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "com.follow.clash"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "0"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "com.follow.clash.debug"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "0"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "com.follow.clash.dev"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "0"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,11 +23,9 @@
|
||||
<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"
|
||||
@@ -46,10 +44,6 @@
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme" />
|
||||
<!-- install-config links go through app_links; Flutter would also push them as routes. -->
|
||||
<meta-data
|
||||
android:name="flutter_deeplinking_enabled"
|
||||
android:value="false" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -113,8 +107,8 @@
|
||||
android:exported="true"
|
||||
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.intent.action.VPN_START_REQUESTED" />
|
||||
<action android:name="${applicationId}.intent.action.VPN_REVOKED" />
|
||||
<action android:name="${applicationId}.intent.action.SERVICE_CREATED" />
|
||||
<action android:name="${applicationId}.intent.action.SERVICE_DESTROYED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
import com.follow.clash.plugins.BatteryPlugin
|
||||
import com.follow.clash.plugins.ServicePlugin
|
||||
import com.follow.clash.plugins.TilePlugin
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
@@ -11,7 +10,6 @@ class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(AppPlugin())
|
||||
flutterEngine.plugins.add(BatteryPlugin())
|
||||
flutterEngine.plugins.add(ServicePlugin())
|
||||
flutterEngine.plugins.add(TilePlugin())
|
||||
ServiceState.attachFlutterEngine(flutterEngine)
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.action
|
||||
import com.follow.clash.common.shortcutId
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class QuickActionActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handle(intent)
|
||||
when (intent.action) {
|
||||
QuickAction.START.action -> GlobalState.launch { ServiceState.handleStartAction() }
|
||||
QuickAction.STOP.action -> GlobalState.launch { ServiceState.handleStopAction() }
|
||||
QuickAction.TOGGLE.action -> {
|
||||
ShortcutManagerCompat.reportShortcutUsed(this, SHORTCUT_ID)
|
||||
GlobalState.launch { ServiceState.handleToggleAction() }
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
// A caller that repeats an action before this instance is gone reaches it here, not onCreate.
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handle(intent)
|
||||
}
|
||||
|
||||
private fun handle(intent: Intent) {
|
||||
val action = QuickAction.entries.firstOrNull { it.action == intent.action } ?: return
|
||||
ShortcutManagerCompat.reportShortcutUsed(this, action.shortcutId)
|
||||
GlobalState.launch {
|
||||
when (action) {
|
||||
QuickAction.START -> ServiceState.handleStartAction()
|
||||
QuickAction.STOP -> ServiceState.handleStopAction()
|
||||
QuickAction.TOGGLE -> ServiceState.handleToggleAction()
|
||||
}
|
||||
}
|
||||
private companion object {
|
||||
const val SHORTCUT_ID = "toggle"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,21 @@ 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 lease = BroadcastLease { pendingResult.finish() }
|
||||
val finished = AtomicBoolean(false)
|
||||
val timeout = Runnable {
|
||||
lease.release { GlobalState.log("Broadcast handling timed out: $action") }
|
||||
if (finished.compareAndSet(false, true)) {
|
||||
GlobalState.log("Broadcast handling timed out: $action")
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
mainHandler.postDelayed(timeout, BROADCAST_TIMEOUT_MILLIS)
|
||||
GlobalState.launch {
|
||||
@@ -27,16 +30,24 @@ class ServiceBroadcastReceiver : BroadcastReceiver() {
|
||||
GlobalState.log("Unable to handle service broadcast $action: $error")
|
||||
} finally {
|
||||
mainHandler.removeCallbacks(timeout)
|
||||
lease.release()
|
||||
if (finished.compareAndSet(false, true)) {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleAction(action: String) {
|
||||
when (action) {
|
||||
BroadcastAction.VPN_START_REQUESTED.action -> ServiceState.handleStartAction()
|
||||
BroadcastAction.SERVICE_CREATED.action -> {
|
||||
GlobalState.log("Background service created")
|
||||
ServiceState.handleStartAction()
|
||||
}
|
||||
|
||||
BroadcastAction.VPN_REVOKED.action -> ServiceState.handleVpnRevokeAction()
|
||||
BroadcastAction.SERVICE_DESTROYED.action -> {
|
||||
GlobalState.log("Background service destroyed")
|
||||
ServiceState.handleStopAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,26 +30,21 @@ import kotlin.coroutines.resume
|
||||
object ServiceController {
|
||||
private val lock = Mutex()
|
||||
private var binding: ManagedServiceBinding? = null
|
||||
|
||||
@Volatile
|
||||
internal var runtime: ServiceRuntime? = null
|
||||
private set
|
||||
private var runTimeMillis = 0L
|
||||
|
||||
suspend fun unbind() = lock.withLock {
|
||||
clearBinding()
|
||||
}
|
||||
|
||||
private fun clearBinding() {
|
||||
binding?.unbind()
|
||||
binding = null
|
||||
}
|
||||
|
||||
// The Core listens exactly while a run exists, so every path that ends one closes it.
|
||||
private fun release() {
|
||||
clearBinding()
|
||||
runtime = null
|
||||
Core.stopListener()
|
||||
}
|
||||
|
||||
fun invokeMethod(data: String, callback: (ByteArray) -> Unit): Result<Unit> = runCatching {
|
||||
fun invokeMethod(data: String, callback: ((String) -> Unit)?): Result<Unit> = runCatching {
|
||||
Core.invokeMethod(data) { result ->
|
||||
callback(result ?: ByteArray(0))
|
||||
callback?.invoke(result.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,11 +59,11 @@ object ServiceController {
|
||||
}
|
||||
}
|
||||
|
||||
fun setEventListener(callback: ((ByteArray) -> Unit)?): Result<Unit> = runCatching {
|
||||
fun setEventListener(callback: ((String?) -> Unit)?): Result<Unit> = runCatching {
|
||||
Core.updateEventListener(callback)
|
||||
}
|
||||
|
||||
suspend fun start(options: VpnOptions): Boolean = lock.withLock {
|
||||
suspend fun start(options: VpnOptions, previousRunTimeMillis: Long): Long = lock.withLock {
|
||||
ServiceConfig.updateVpnOptions(options)
|
||||
val nextIntent = if (options.enable) {
|
||||
VpnService::class.intent
|
||||
@@ -77,9 +72,7 @@ object ServiceController {
|
||||
}
|
||||
|
||||
if (binding?.component != nextIntent.component) {
|
||||
if (binding != null) {
|
||||
tearDownServices()
|
||||
}
|
||||
clearBinding()
|
||||
lateinit var nextBinding: ManagedServiceBinding
|
||||
nextBinding = ManagedServiceBinding(nextIntent) { message ->
|
||||
handleServiceDisconnected(nextBinding, message)
|
||||
@@ -87,12 +80,13 @@ object ServiceController {
|
||||
binding = nextBinding
|
||||
nextBinding.bind().onFailure { error ->
|
||||
GlobalState.log("Unable to bind background service: $error")
|
||||
release()
|
||||
return@withLock false
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
return@withLock runTimeMillis
|
||||
}
|
||||
}
|
||||
|
||||
val currentBinding = binding ?: return@withLock false
|
||||
val currentBinding = binding ?: return@withLock 0L
|
||||
val result = currentBinding.useService { service -> service.start() }
|
||||
if (result.isFailure) {
|
||||
GlobalState.log("Unable to start background service: ${result.exceptionOrNull()}")
|
||||
@@ -100,60 +94,40 @@ object ServiceController {
|
||||
.onFailure { error ->
|
||||
GlobalState.log("Unable to clean up failed background service start: $error")
|
||||
}
|
||||
release()
|
||||
return@withLock false
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
return@withLock runTimeMillis
|
||||
}
|
||||
|
||||
Core.startListener()
|
||||
runtime = ServiceRuntime(
|
||||
vpn = options.enable,
|
||||
startedAtMillis = runtime?.startedAtMillis ?: System.currentTimeMillis(),
|
||||
)
|
||||
true
|
||||
runTimeMillis = previousRunTimeMillis.takeIf { it != 0L }
|
||||
?: System.currentTimeMillis()
|
||||
runTimeMillis
|
||||
}
|
||||
|
||||
suspend fun stop() = lock.withLock {
|
||||
tearDownServices()
|
||||
release()
|
||||
}
|
||||
|
||||
private suspend fun tearDownServices() {
|
||||
suspend fun stop(): Long = lock.withLock {
|
||||
binding?.useService { service -> service.stop() }
|
||||
?.onFailure { error ->
|
||||
GlobalState.log("Unable to stop background service: $error")
|
||||
}
|
||||
clearBinding()
|
||||
stopServices()
|
||||
runTimeMillis = 0L
|
||||
runTimeMillis
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
fun getRunTimeMillis(): Long = runTimeMillis
|
||||
|
||||
private fun handleServiceDisconnected(
|
||||
disconnectedBinding: ManagedServiceBinding,
|
||||
message: String,
|
||||
) {
|
||||
val token = ServiceState.captureRequestToken()
|
||||
GlobalState.launch {
|
||||
val wasCurrent = lock.withLock {
|
||||
lock.withLock {
|
||||
if (binding !== disconnectedBinding) {
|
||||
return@withLock false
|
||||
return@withLock
|
||||
}
|
||||
GlobalState.log("Background service disconnected: $message")
|
||||
release()
|
||||
true
|
||||
}
|
||||
if (wasCurrent) {
|
||||
ServiceState.handleServiceLost(token)
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,284 @@
|
||||
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 machine = ServiceStateMachine(AndroidServiceStateHost)
|
||||
private val transitionLock = Mutex()
|
||||
private val startPreparationLock = Mutex()
|
||||
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
|
||||
private val latestRequest = AtomicReference(RunRequest(running = false))
|
||||
|
||||
val runState = machine.runState
|
||||
@Volatile
|
||||
private var sharedState = SharedState()
|
||||
|
||||
val isRunRequested: Boolean
|
||||
get() = machine.isRunRequested
|
||||
@Volatile
|
||||
private var flutterEngine: FlutterEngine? = null
|
||||
|
||||
fun attachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.attachFlutterEngine(engine)
|
||||
val runState = mutableRunState.asStateFlow()
|
||||
|
||||
fun detachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.detachFlutterEngine(engine)
|
||||
var runTimeMillis = 0L
|
||||
private set
|
||||
|
||||
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 awaitRunTime(): Long = machine.awaitRunTime()
|
||||
suspend fun handleToggleAction() {
|
||||
if (isRunningRequested()) {
|
||||
handleStopAction()
|
||||
} else {
|
||||
handleStartAction()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestStart(): Deferred<Boolean> = machine.requestStart()
|
||||
suspend fun refresh() = transitionLock.withLock {
|
||||
runTimeMillis = ServiceController.getRunTimeMillis()
|
||||
mutableRunState.value = if (runTimeMillis == 0L) RunState.STOPPED else RunState.STARTED
|
||||
}
|
||||
|
||||
fun requestStop(): Deferred<Boolean> = machine.requestStop()
|
||||
suspend fun handleStartAction() {
|
||||
if (isRunningRequested()) {
|
||||
return
|
||||
}
|
||||
if (flutterEngine != null) {
|
||||
tilePlugin?.handleStart()
|
||||
return
|
||||
}
|
||||
loadPreferencesAndStart()
|
||||
}
|
||||
|
||||
fun syncSharedState(state: SharedState) = machine.syncSharedState(state)
|
||||
suspend fun handleStopAction() {
|
||||
if (!isRunningRequested()) {
|
||||
return
|
||||
}
|
||||
if (flutterEngine != null) {
|
||||
tilePlugin?.handleStop()
|
||||
return
|
||||
}
|
||||
GlobalState.application.showToast(sharedState.stopTip)
|
||||
requestStop().await()
|
||||
}
|
||||
|
||||
internal fun captureRequestToken(): RunRequest = machine.captureRequestToken()
|
||||
fun requestStart(): Deferred<Boolean> {
|
||||
val request = createRequest(running = true)
|
||||
val result = CompletableDeferred<Boolean>()
|
||||
val launchRequest = {
|
||||
GlobalState.launch {
|
||||
result.complete(
|
||||
runCatching { start(request) }
|
||||
.onFailure { error ->
|
||||
GlobalState.log("Unable to process service start request: $error")
|
||||
fail(request)
|
||||
}
|
||||
.getOrDefault(false),
|
||||
)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
appPlugin?.requestNotificationPermission(launchRequest) ?: launchRequest()
|
||||
return result
|
||||
}
|
||||
|
||||
internal suspend fun handleServiceLost(token: RunRequest) = machine.handleServiceLost(token)
|
||||
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
|
||||
runTimeMillis = ServiceController.start(options, runTimeMillis)
|
||||
mutableRunState.value =
|
||||
if (runTimeMillis == 0L) RunState.STOPPED else RunState.STARTED
|
||||
if (runTimeMillis == 0L) {
|
||||
fail(request)
|
||||
return@transition false
|
||||
}
|
||||
isCurrent(request)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
|
||||
if (!isCurrent(request)) {
|
||||
return@withLock false
|
||||
}
|
||||
if (runState.value == RunState.STOPPED && runTimeMillis == 0L) {
|
||||
return@withLock true
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPING
|
||||
runTimeMillis = ServiceController.stop()
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
isCurrent(request)
|
||||
}
|
||||
|
||||
private suspend fun prepareVpn(options: VpnOptions): Boolean {
|
||||
val plugin = appPlugin
|
||||
?: return !options.enable || VpnService.prepare(GlobalState.application) == null
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
plugin.prepareVpn(options.enable) { granted ->
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRequest(running: Boolean): RunRequest =
|
||||
RunRequest(running).also(latestRequest::set)
|
||||
|
||||
private fun isRunningRequested(): Boolean = latestRequest.get().running
|
||||
|
||||
private fun isCurrent(request: RunRequest): Boolean = latestRequest.get() === request
|
||||
|
||||
private fun fail(request: RunRequest) {
|
||||
latestRequest.compareAndSet(request, RunRequest(running = false))
|
||||
}
|
||||
|
||||
private class RunRequest(
|
||||
val running: Boolean,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
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.ServicePlugin
|
||||
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
|
||||
|
||||
internal data class ServiceRuntime(
|
||||
val vpn: Boolean,
|
||||
val startedAtMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 runtime: ServiceRuntime?
|
||||
val homeDirPath: String
|
||||
val sdkInt: Int
|
||||
|
||||
fun log(message: String)
|
||||
|
||||
fun showToast(message: String)
|
||||
|
||||
fun updateNotificationParams(params: NotificationParams)
|
||||
|
||||
fun loadSharedState(): SharedState
|
||||
|
||||
fun isVpnPermissionGranted(): Boolean
|
||||
|
||||
fun isLocalNetworkPermissionGranted(): Boolean
|
||||
|
||||
fun tile(): TileGateway?
|
||||
|
||||
fun app(): AppGateway?
|
||||
|
||||
fun notifyStopped()
|
||||
|
||||
suspend fun quickSetup(initParams: String, setupParams: String): Result<String>
|
||||
|
||||
suspend fun startService(options: VpnOptions): Boolean
|
||||
|
||||
suspend fun stopService()
|
||||
}
|
||||
|
||||
internal fun interface TileGateway {
|
||||
fun handleStart()
|
||||
}
|
||||
|
||||
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 runtime: ServiceRuntime?
|
||||
get() = ServiceController.runtime
|
||||
|
||||
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 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 ->
|
||||
TileGateway(plugin::handleStart)
|
||||
}
|
||||
|
||||
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 fun notifyStopped() {
|
||||
flutterEngine?.plugin<ServicePlugin>()?.notifyStopped()
|
||||
}
|
||||
|
||||
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> =
|
||||
ServiceController.quickSetup(initParams, setupParams)
|
||||
|
||||
override suspend fun startService(options: VpnOptions): Boolean =
|
||||
ServiceController.start(options)
|
||||
|
||||
override suspend fun stopService() = ServiceController.stop()
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
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.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
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."
|
||||
|
||||
private enum class StartOutcome {
|
||||
STARTED,
|
||||
FAILED,
|
||||
SUPERSEDED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Callers request a transition; the newest request always wins. No step assigns [runState]:
|
||||
* [publish] derives it from the latest intent, the transition in flight and what the host really
|
||||
* runs, so no path can leave it claiming a service that is not there.
|
||||
*/
|
||||
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
|
||||
|
||||
@Volatile
|
||||
private var transition: RunState? = null
|
||||
|
||||
val runState = mutableRunState.asStateFlow()
|
||||
|
||||
val isRunRequested: Boolean
|
||||
get() = arbiter.isRunningRequested
|
||||
|
||||
suspend fun handleToggleAction() {
|
||||
if (isRunRequested) {
|
||||
handleStopAction()
|
||||
} else {
|
||||
handleStartAction()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitRunTime(): Long = transitionLock.withLock {
|
||||
host.runtime?.startedAtMillis?.takeIf { isRunRequested } ?: 0L
|
||||
}
|
||||
|
||||
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) {
|
||||
val dropped = transitionLock.withLock {
|
||||
if (host.runtime != null) {
|
||||
return@withLock false
|
||||
}
|
||||
arbiter.resetToStopped(token).also { publish() }
|
||||
}
|
||||
if (dropped) {
|
||||
host.notifyStopped()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleStartAction() {
|
||||
if (isRunRequested) {
|
||||
return
|
||||
}
|
||||
val tile = host.tile()
|
||||
if (tile != null) {
|
||||
tile.handleStart()
|
||||
return
|
||||
}
|
||||
startFromPreferences()
|
||||
}
|
||||
|
||||
suspend fun handleStopAction() {
|
||||
if (!isRunRequested) {
|
||||
return
|
||||
}
|
||||
host.showToast(sharedState.stopTip)
|
||||
requestStop().await()
|
||||
host.notifyStopped()
|
||||
}
|
||||
|
||||
suspend fun handleVpnRevokeAction() {
|
||||
if (host.runtime?.vpn != true) {
|
||||
return
|
||||
}
|
||||
handleStopAction()
|
||||
}
|
||||
|
||||
fun requestStart(): Deferred<Boolean> {
|
||||
if (isStartedAs(sharedState.vpnOptions)) {
|
||||
return CompletableDeferred(true)
|
||||
}
|
||||
val outcome = launchStart(createRequest(running = true))
|
||||
return host.scope.async { outcome.await() == StartOutcome.STARTED }
|
||||
}
|
||||
|
||||
fun requestStop(): Deferred<Boolean> {
|
||||
val request = createRequest(running = false)
|
||||
return host.scope.async { stop(request) }
|
||||
}
|
||||
|
||||
fun syncSharedState(state: SharedState) {
|
||||
sharedState = state
|
||||
applySharedState()
|
||||
}
|
||||
|
||||
// No new request for a settled run: one refused at a prompt would take the service down.
|
||||
private fun isStartedAs(options: VpnOptions?): Boolean =
|
||||
runState.value == RunState.STARTED && host.runtime?.vpn == options?.enable
|
||||
|
||||
// The intent is claimed before the Core is set up, so a second action that lands meanwhile
|
||||
// toggles this start off instead of racing a second setup against it.
|
||||
private suspend fun startFromPreferences() {
|
||||
val request = createRequest(running = true)
|
||||
sharedState = host.loadSharedState()
|
||||
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
|
||||
host.showToast(MISSING_CONFIG_MESSAGE)
|
||||
abandon(request)
|
||||
return
|
||||
}
|
||||
if (!setupCore()) {
|
||||
abandon(request)
|
||||
} else if (launchStart(request).await() == StartOutcome.FAILED) {
|
||||
host.showToast(START_FAILED_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchStart(request: RunRequest): Deferred<StartOutcome> {
|
||||
val result = CompletableDeferred<StartOutcome>()
|
||||
val launch: (Boolean) -> Unit = { permitted ->
|
||||
host.scope.launch {
|
||||
result.complete(if (permitted) start(request) else abandon(request))
|
||||
}
|
||||
}
|
||||
val app = host.app()
|
||||
if (app != null) {
|
||||
app.requestNotificationPermission { permitted ->
|
||||
if (permitted) {
|
||||
app.requestLocalNetworkPermission { launch(true) }
|
||||
} else {
|
||||
launch(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launch(true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun applySharedState() {
|
||||
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): StartOutcome {
|
||||
val started = try {
|
||||
startPreparationLock.withLock { runStart(request) }
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
host.log("Unable to process service start request: $error")
|
||||
false
|
||||
}
|
||||
return if (started) StartOutcome.STARTED else abandon(request)
|
||||
}
|
||||
|
||||
private suspend fun abandon(request: RunRequest): StartOutcome {
|
||||
val dropped = arbiter.resetToStopped(request)
|
||||
reconcileStopped()
|
||||
if (!dropped) {
|
||||
return StartOutcome.SUPERSEDED
|
||||
}
|
||||
host.notifyStopped()
|
||||
return StartOutcome.FAILED
|
||||
}
|
||||
|
||||
private suspend fun runStart(request: RunRequest): Boolean {
|
||||
if (!isCurrent(request)) {
|
||||
return false
|
||||
}
|
||||
val options = sharedState.vpnOptions ?: return false
|
||||
if (!prepareVpn(options)) {
|
||||
if (host.app() == null && isCurrent(request)) {
|
||||
host.showToast(VPN_PERMISSION_MESSAGE)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return transitionLock.withLock {
|
||||
when {
|
||||
!isCurrent(request) -> false
|
||||
host.runtime?.vpn == options.enable -> true
|
||||
else -> transitioning(RunState.STARTING) {
|
||||
host.startService(withLocalNetworkFallback(options)) && isCurrent(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Flutter's startListener and the native quickSetup both open the Core's listeners before any
|
||||
// service exists to answer for them, and Flutter may be gone before it hears of the failure.
|
||||
private suspend fun reconcileStopped() = transitionLock.withLock {
|
||||
when {
|
||||
isRunRequested -> publish()
|
||||
host.runtime != null -> stopLocked()
|
||||
else -> {
|
||||
stopService()
|
||||
publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
|
||||
if (!isCurrent(request)) {
|
||||
return@withLock false
|
||||
}
|
||||
abandonVpnPreparation()
|
||||
if (host.runtime != null) {
|
||||
stopLocked()
|
||||
}
|
||||
isCurrent(request)
|
||||
}
|
||||
|
||||
private suspend fun stopLocked() = transitioning(RunState.STOPPING) { stopService() }
|
||||
|
||||
private suspend fun stopService() {
|
||||
try {
|
||||
host.stopService()
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
host.log("Unable to stop service: $error")
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> transitioning(state: RunState, block: () -> T): T {
|
||||
transition = state
|
||||
publish()
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
transition = null
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun publish() {
|
||||
mutableRunState.value = transition ?: when {
|
||||
host.runtime == null -> RunState.STOPPED
|
||||
isRunRequested -> RunState.STARTED
|
||||
else -> RunState.STOPPING
|
||||
}
|
||||
}
|
||||
|
||||
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).also { publish() }
|
||||
|
||||
private fun isCurrent(request: RunRequest): Boolean = arbiter.isCurrent(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -20,14 +20,12 @@ class TileService : android.service.quicksettings.TileService() {
|
||||
scope?.cancel()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { scope ->
|
||||
scope.launch {
|
||||
ServiceState.refresh()
|
||||
ServiceState.runState.collect(::updateTile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,12 +6,10 @@ 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,
|
||||
)
|
||||
@@ -21,6 +19,4 @@ data class SetupParams(
|
||||
val testUrl: String,
|
||||
@SerializedName("selected-map")
|
||||
val selectedMap: Map<String, String>,
|
||||
@SerializedName("skip-cert-verify")
|
||||
val skipCertVerify: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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,26 +14,8 @@ internal class PackageResolver(
|
||||
private val packageManager: PackageManager,
|
||||
private val appPackageName: String,
|
||||
) {
|
||||
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 }
|
||||
val installedPackages: List<InstalledPackage> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
loadPackages()
|
||||
}
|
||||
|
||||
fun getChinaPackageNames(): List<String> = installedPackages
|
||||
@@ -70,16 +52,16 @@ internal class PackageResolver(
|
||||
}
|
||||
|
||||
private fun isChinaPackage(packageName: String): Boolean {
|
||||
if (ChinaPackageMatcher.isSkipped(packageName)) {
|
||||
if (SKIPPED_PREFIXES.any { packageName == it || packageName.startsWith("$it.") }) {
|
||||
return false
|
||||
}
|
||||
if (ChinaPackageMatcher.matchesKnownPrefix(packageName)) {
|
||||
if (packageName.matches(CHINA_PACKAGE_REGEX)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val packageInfo = getPackageInfo(packageName)
|
||||
packageInfo.componentNames().any(ChinaPackageMatcher::matchesKnownPrefix) ||
|
||||
packageInfo.componentNames().any { it.matches(CHINA_PACKAGE_REGEX) } ||
|
||||
packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -118,22 +100,75 @@ internal class PackageResolver(
|
||||
DexBackedDexFile.fromInputStream(null, input)
|
||||
}
|
||||
dexFile.classes.any { clazz ->
|
||||
ChinaPackageMatcher.matchesKnownPrefix(
|
||||
ChinaPackageMatcher.classNameOf(clazz.type),
|
||||
)
|
||||
clazz.type
|
||||
.removeSurrounding("L", ";")
|
||||
.replace('/', '.')
|
||||
.replace('$', '.')
|
||||
.matches(CHINA_PACKAGE_REGEX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val GET_INSTALLED_APPS = "com.android.permission.GET_INSTALLED_APPS"
|
||||
private companion object {
|
||||
const val ANDROID_PACKAGE_NAME = "android"
|
||||
const val MAX_DEX_SIZE_BYTES = 15_000_000L
|
||||
|
||||
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
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
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
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -20,17 +17,11 @@ import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
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.common.shortcutId
|
||||
import com.follow.clash.getPackageIconPath
|
||||
import com.follow.clash.packages.PackageResolver
|
||||
import com.follow.clash.showToast
|
||||
@@ -41,7 +32,6 @@ 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
|
||||
@@ -52,32 +42,16 @@ 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 val vpnPrepareCallback = PendingCallback<Boolean>()
|
||||
private var vpnPrepareCallback: ((Boolean) -> Unit)? = null
|
||||
|
||||
private val requestNotificationCallback = PendingCallback<Boolean>()
|
||||
|
||||
private val requestInstalledAppsCallback = PendingCallback<Boolean>()
|
||||
|
||||
private val requestLocalNetworkCallback = PendingCallback<Boolean>()
|
||||
private var requestNotificationCallback: (() -> Unit)? = null
|
||||
|
||||
private var isRequestingNotificationPermission = false
|
||||
|
||||
private var isRequestingLocalNetworkPermission = false
|
||||
|
||||
private var skipLocalNetworkPermissionRequest = false
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
private val packageResolver by lazy {
|
||||
@@ -87,61 +61,8 @@ 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
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private val isTelevision by lazy {
|
||||
val packageManager = GlobalState.application.packageManager
|
||||
packageManager.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
|
||||
packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
|
||||
}
|
||||
|
||||
private var floatingIme = false
|
||||
|
||||
// A TV keyboard floats over the app and reports a zero-height IME inset.
|
||||
private val imeInsetsListener = OnApplyWindowInsetsListener { view, insets ->
|
||||
val ime = WindowInsetsCompat.Type.ime()
|
||||
reportFloatingIme(insets.isVisible(ime) && insets.getInsets(ime).bottom == 0)
|
||||
ViewCompat.onApplyWindowInsets(view, insets)
|
||||
}
|
||||
|
||||
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, result: Result) {
|
||||
when (call.method) {
|
||||
"moveTaskToBack" -> {
|
||||
@@ -156,33 +77,25 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
|
||||
"initShortcuts" -> {
|
||||
val labels = shortcutLabels(call.arguments)
|
||||
if (labels == null) {
|
||||
result.error(
|
||||
"INVALID_ARGUMENT",
|
||||
"Shortcut labels must map every quick action to a string",
|
||||
null,
|
||||
)
|
||||
val label = call.arguments as? String
|
||||
if (label == null) {
|
||||
result.error("INVALID_ARGUMENT", "Shortcut label must be a string", null)
|
||||
} else {
|
||||
initShortcuts(labels)
|
||||
initShortcuts(label)
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
|
||||
"getPackages" -> reply(result) {
|
||||
gson.toJson(packageResolver.installedPackages)
|
||||
"getPackages" -> {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
result.success(gson.toJson(packageResolver.installedPackages))
|
||||
}
|
||||
}
|
||||
|
||||
"getChinaPackageNames" -> reply(result) {
|
||||
gson.toJson(packageResolver.getChinaPackageNames())
|
||||
}
|
||||
|
||||
"isInstalledAppsPermissionGranted" -> reply(result) {
|
||||
packageResolver.hasInstalledAppsPermission()
|
||||
}
|
||||
|
||||
"requestInstalledAppsPermission" -> {
|
||||
requestInstalledAppsPermission { granted -> result.success(granted) }
|
||||
"getChinaPackageNames" -> {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
result.success(gson.toJson(packageResolver.getChinaPackageNames()))
|
||||
}
|
||||
}
|
||||
|
||||
"getPackageIcon" -> {
|
||||
@@ -195,12 +108,20 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"isBatteryOptimizationDisabled" -> {
|
||||
result.success(isBatteryOptimizationDisabled())
|
||||
}
|
||||
|
||||
"openBatteryOptimizationSettings" -> {
|
||||
result.success(openBatteryOptimizationSettings())
|
||||
}
|
||||
|
||||
"openAppSettings" -> {
|
||||
result.success(openAppSettings())
|
||||
}
|
||||
|
||||
"getLastExitInfo" -> reply(result) {
|
||||
GlobalState.lastExitInfo()
|
||||
"didCrashOnPreviousExecution" -> {
|
||||
result.success(GlobalState.didCrashOnPreviousExecution())
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -209,53 +130,57 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
private fun handleGetPackageIcon(call: MethodCall, result: Result) {
|
||||
scope.launch {
|
||||
val packageName = call.argument<String>("packageName")
|
||||
if (packageName == null) {
|
||||
result.success("")
|
||||
return@launch
|
||||
}
|
||||
val path = GlobalState.application.packageManager.getPackageIconPath(packageName)
|
||||
result.success(path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortcutLabels(arguments: Any?): Map<QuickAction, String>? {
|
||||
val map = arguments as? Map<*, *> ?: return null
|
||||
return QuickAction.entries.associateWith { action ->
|
||||
map[action.shortcutId] as? String ?: return null
|
||||
private fun initShortcuts(label: String) {
|
||||
val shortcut = with(ShortcutInfoCompat.Builder(GlobalState.application, "toggle")) {
|
||||
setShortLabel(label)
|
||||
setIcon(
|
||||
IconCompat.createWithResource(
|
||||
GlobalState.application,
|
||||
R.mipmap.ic_launcher_round,
|
||||
),
|
||||
)
|
||||
setIntent(QuickAction.TOGGLE.quickIntent)
|
||||
build()
|
||||
}
|
||||
ShortcutManagerCompat.setDynamicShortcuts(
|
||||
GlobalState.application,
|
||||
listOf(shortcut),
|
||||
)
|
||||
}
|
||||
|
||||
private fun initShortcuts(labels: Map<QuickAction, String>) {
|
||||
val shortcuts = SHORTCUT_ORDER.mapIndexed { rank, action ->
|
||||
with(ShortcutInfoCompat.Builder(GlobalState.application, action.shortcutId)) {
|
||||
setShortLabel(labels.getValue(action))
|
||||
setIcon(IconCompat.createWithResource(GlobalState.application, action.shortcutIcon))
|
||||
setIntent(action.quickIntent)
|
||||
setRank(rank)
|
||||
build()
|
||||
private fun isBatteryOptimizationDisabled(): Boolean {
|
||||
val powerManager = getSystemService(GlobalState.application, PowerManager::class.java)
|
||||
return powerManager?.isIgnoringBatteryOptimizations(GlobalState.application.packageName)
|
||||
?: false
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun openBatteryOptimizationSettings(): Boolean {
|
||||
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
|
||||
val activity = activity ?: return false
|
||||
return try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = "package:${GlobalState.application.packageName}".toUri()
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
ShortcutManagerCompat.setDynamicShortcuts(GlobalState.application, shortcuts)
|
||||
}
|
||||
|
||||
// Plain circles, not adaptive icons: an adaptive shortcut icon takes the
|
||||
// launcher's own mask, which is a square on many devices.
|
||||
private val QuickAction.shortcutIcon: Int
|
||||
get() = when (this) {
|
||||
QuickAction.START -> R.drawable.ic_shortcut_start
|
||||
QuickAction.STOP -> R.drawable.ic_shortcut_stop
|
||||
QuickAction.TOGGLE -> R.drawable.ic_shortcut_toggle
|
||||
}
|
||||
|
||||
private fun openAppSettings(): Boolean {
|
||||
val activity = activity ?: return false
|
||||
return try {
|
||||
@@ -271,31 +196,31 @@ 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 == taskId
|
||||
it.taskInfo.taskId == activity?.taskId
|
||||
} else {
|
||||
it.taskInfo?.id == taskId
|
||||
it.taskInfo.id == activity?.taskId
|
||||
}
|
||||
}
|
||||
task?.setExcludeFromRecents(value ?: false)
|
||||
}
|
||||
|
||||
fun requestNotificationPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestNotificationCallback.replace(callback, supersededValue = false)
|
||||
fun requestNotificationPermission(callback: () -> Unit) {
|
||||
requestNotificationCallback?.invoke()
|
||||
requestNotificationCallback = callback
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val permission = ContextCompat.checkSelfPermission(
|
||||
GlobalState.application,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
|
||||
invokeRequestNotificationCallback(true)
|
||||
return@onMainThread
|
||||
invokeRequestNotificationCallback()
|
||||
return
|
||||
}
|
||||
if (isRequestingNotificationPermission) {
|
||||
return@onMainThread
|
||||
return
|
||||
}
|
||||
isRequestingNotificationPermission = true
|
||||
activity?.let {
|
||||
@@ -304,83 +229,23 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
} ?: invokeRequestNotificationCallback(true)
|
||||
return@onMainThread
|
||||
} ?: invokeRequestNotificationCallback()
|
||||
return
|
||||
}
|
||||
invokeRequestNotificationCallback(true)
|
||||
invokeRequestNotificationCallback()
|
||||
}
|
||||
|
||||
private fun invokeRequestNotificationCallback(shouldStart: Boolean) {
|
||||
private fun invokeRequestNotificationCallback() {
|
||||
isRequestingNotificationPermission = false
|
||||
requestNotificationCallback.resolve(shouldStart)
|
||||
requestNotificationCallback?.invoke()
|
||||
requestNotificationCallback = null
|
||||
}
|
||||
|
||||
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)
|
||||
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) {
|
||||
vpnPrepareCallback = callback
|
||||
if (!needPrepare) {
|
||||
invokeVpnPrepareCallback(true)
|
||||
return@onMainThread
|
||||
return
|
||||
}
|
||||
val intent = VpnService.prepare(GlobalState.application)
|
||||
if (intent != null) {
|
||||
@@ -391,19 +256,14 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
@Suppress("DEPRECATION")
|
||||
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
return@onMainThread
|
||||
return
|
||||
}
|
||||
invokeVpnPrepareCallback(true)
|
||||
}
|
||||
|
||||
// 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.resolve(granted)
|
||||
vpnPrepareCallback?.invoke(granted)
|
||||
vpnPrepareCallback = null
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
@@ -411,29 +271,14 @@ 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)
|
||||
vpnPrepareCallback = null
|
||||
requestNotificationCallback = null
|
||||
isRequestingNotificationPermission = false
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
@@ -441,43 +286,13 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
|
||||
private fun attachToActivity(binding: ActivityPluginBinding) {
|
||||
detachFromActivity()
|
||||
activityBinding = binding
|
||||
activity = binding.activity
|
||||
binding.addActivityResultListener(activityResultListener)
|
||||
binding.addRequestPermissionsResultListener(permissionsResultListener)
|
||||
if (isTelevision) {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(
|
||||
binding.activity.window.decorView,
|
||||
imeInsetsListener,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun detachFromActivity() {
|
||||
if (isTelevision) {
|
||||
activity?.window?.decorView?.let {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(it, null)
|
||||
}
|
||||
reportFloatingIme(false)
|
||||
}
|
||||
activity = null
|
||||
val binding = activityBinding ?: return
|
||||
activityBinding = null
|
||||
binding.removeActivityResultListener(activityResultListener)
|
||||
binding.removeRequestPermissionsResultListener(permissionsResultListener)
|
||||
}
|
||||
|
||||
private fun reportFloatingIme(floating: Boolean) {
|
||||
if (floating == floatingIme) {
|
||||
return
|
||||
}
|
||||
floatingIme = floating
|
||||
channel.invokeMethod("floatingImeChanged", floating)
|
||||
binding.addActivityResultListener(::onActivityResult)
|
||||
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
detachFromActivity()
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
@@ -486,11 +301,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
channel.invokeMethod("exit", null)
|
||||
detachFromActivity()
|
||||
invokeVpnPrepareCallback(false)
|
||||
invokeRequestNotificationCallback(false)
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
activity = null
|
||||
vpnPrepareCallback = null
|
||||
requestNotificationCallback = null
|
||||
isRequestingNotificationPermission = false
|
||||
}
|
||||
|
||||
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
@@ -505,38 +319,17 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray,
|
||||
): Boolean = when (requestCode) {
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE -> {
|
||||
skipNotificationPermissionRequest = true
|
||||
invokeRequestNotificationCallback(true)
|
||||
true
|
||||
): Boolean {
|
||||
if (requestCode != NOTIFICATION_PERMISSION_REQUEST_CODE) {
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
skipNotificationPermissionRequest = true
|
||||
invokeRequestNotificationCallback()
|
||||
return true
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SHORTCUT_ORDER = listOf(QuickAction.START, QuickAction.STOP, QuickAction.TOGGLE)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.database.ContentObserver
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.net.toUri
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.common.PendingCallback
|
||||
import com.follow.clash.common.registerReceiverCompat
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
|
||||
class BatteryPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware,
|
||||
EventChannel.StreamHandler {
|
||||
|
||||
private lateinit var context: Context
|
||||
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
|
||||
private lateinit var eventChannel: EventChannel
|
||||
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
private val pendingRequest = PendingCallback<Unit>()
|
||||
|
||||
private var events: EventChannel.EventSink? = null
|
||||
|
||||
private var lastPublished: Boolean? = null
|
||||
|
||||
private val activityResultListener =
|
||||
PluginRegistry.ActivityResultListener { requestCode, _, _ ->
|
||||
if (requestCode != REQUEST_CODE) {
|
||||
return@ActivityResultListener false
|
||||
}
|
||||
resolve()
|
||||
true
|
||||
}
|
||||
|
||||
private val allowlistReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
private val saverObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = binding.applicationContext
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/battery")
|
||||
methodChannel.setMethodCallHandler(this)
|
||||
eventChannel = EventChannel(
|
||||
binding.binaryMessenger,
|
||||
"${Components.PACKAGE_NAME}/battery/optimization",
|
||||
)
|
||||
eventChannel.setStreamHandler(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
eventChannel.setStreamHandler(null)
|
||||
onCancel(null)
|
||||
resolve()
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"requestIgnoreOptimizations" -> requestIgnoreOptimizations { result.success(null) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
|
||||
val ignoring = isIgnoringOptimizations()
|
||||
sink.success(ignoring)
|
||||
context.registerReceiverCompat(
|
||||
allowlistReceiver,
|
||||
IntentFilter(ACTION_POWER_SAVE_WHITELIST_CHANGED),
|
||||
)
|
||||
context.contentResolver.registerContentObserver(
|
||||
Settings.System.getUriFor(MILLET_NO_RESTRICT_APP),
|
||||
false,
|
||||
saverObserver,
|
||||
)
|
||||
events = sink
|
||||
lastPublished = ignoring
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
events ?: return
|
||||
events = null
|
||||
context.unregisterReceiver(allowlistReceiver)
|
||||
context.contentResolver.unregisterContentObserver(saverObserver)
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
val sink = events ?: return
|
||||
val ignoring = isIgnoringOptimizations()
|
||||
if (ignoring == lastPublished) {
|
||||
return
|
||||
}
|
||||
lastPublished = ignoring
|
||||
sink.success(ignoring)
|
||||
}
|
||||
|
||||
private fun resolve() {
|
||||
publish()
|
||||
pendingRequest.resolve(Unit)
|
||||
}
|
||||
|
||||
private fun requestIgnoreOptimizations(callback: (Unit) -> Unit) {
|
||||
val activity = activityBinding?.activity
|
||||
if (activity == null || isIgnoringOptimizations()) {
|
||||
callback(Unit)
|
||||
return
|
||||
}
|
||||
pendingRequest.replace(callback, supersededValue = Unit)
|
||||
val pages = if (isXiaomi) saverPages() else optimizationPages()
|
||||
if (pages.none { launch(activity, it) }) {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun optimizationPages(): List<Intent> {
|
||||
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
|
||||
val prompt = Intent(
|
||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
"package:${context.packageName}".toUri(),
|
||||
)
|
||||
// Some ROMs drop the direct prompt; the allowlist page still lets the user exempt the app.
|
||||
val allowlist = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
return listOf(prompt, allowlist)
|
||||
}
|
||||
|
||||
private fun saverPages(): List<Intent> {
|
||||
val policy = Intent()
|
||||
.setComponent(ComponentName(POWERKEEPER_PACKAGE, POWERKEEPER_POLICY_ACTIVITY))
|
||||
.putExtra("package_name", context.packageName)
|
||||
.putExtra(
|
||||
"package_label",
|
||||
context.applicationInfo.loadLabel(context.packageManager).toString(),
|
||||
)
|
||||
val details = Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
"package:${context.packageName}".toUri(),
|
||||
)
|
||||
return listOf(policy, details)
|
||||
}
|
||||
|
||||
private fun launch(activity: Activity, page: Intent): Boolean = try {
|
||||
@Suppress("DEPRECATION")
|
||||
activity.startActivityForResult(page, REQUEST_CODE)
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
false
|
||||
} catch (_: SecurityException) {
|
||||
false
|
||||
}
|
||||
|
||||
// On Xiaomi the battery saver is what counts: "No restrictions" also allowlists the
|
||||
// app, the allowlist alone does not stop PowerKeeper freezing it, and PowerKeeper
|
||||
// drops a running VPN from the allowlist while the list follows the user's policy.
|
||||
private fun isIgnoringOptimizations(): Boolean {
|
||||
if (isXiaomi) {
|
||||
return context.packageName in noRestrictionApps()
|
||||
}
|
||||
return context.getSystemService(PowerManager::class.java)
|
||||
?.isIgnoringBatteryOptimizations(context.packageName) ?: false
|
||||
}
|
||||
|
||||
// PowerKeeper's comma-separated "No restrictions" packages, kept for user 0 only.
|
||||
private fun noRestrictionApps(): List<String> {
|
||||
val packages = try {
|
||||
Settings.System.getString(context.contentResolver, MILLET_NO_RESTRICT_APP)
|
||||
} catch (_: SecurityException) {
|
||||
null
|
||||
}
|
||||
return packages?.split(',')?.map { it.trim() }.orEmpty()
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
binding.addActivityResultListener(activityResultListener)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
detachFromActivity()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
detachFromActivity()
|
||||
resolve()
|
||||
}
|
||||
|
||||
private fun detachFromActivity() {
|
||||
activityBinding?.removeActivityResultListener(activityResultListener)
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val isXiaomi = Build.MANUFACTURER.equals("xiaomi", ignoreCase = true)
|
||||
|
||||
// Every plugin on the activity sees every result; AppPlugin holds 1001-1004.
|
||||
const val REQUEST_CODE = 1005
|
||||
|
||||
// PowerManager keeps this action @hide, yet the system broadcasts it to
|
||||
// registered receivers of the system user whenever the allowlist changes.
|
||||
const val ACTION_POWER_SAVE_WHITELIST_CHANGED =
|
||||
"android.os.action.POWER_SAVE_WHITELIST_CHANGED"
|
||||
|
||||
const val MILLET_NO_RESTRICT_APP = "MILLET_NO_RESTRICT_APP"
|
||||
|
||||
const val POWERKEEPER_PACKAGE = "com.miui.powerkeeper"
|
||||
|
||||
const val POWERKEEPER_POLICY_ACTIVITY = "com.miui.powerkeeper.ui.HiddenAppsConfigActivity"
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,8 @@ import com.follow.clash.common.Components
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -17,31 +15,18 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
private lateinit var messenger: BinaryMessenger
|
||||
private lateinit var channel: MethodChannel
|
||||
private lateinit var coreChannel: MethodChannel
|
||||
private lateinit var scope: CoroutineScope
|
||||
private val gson = Gson()
|
||||
private var commandRevision = 0
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
messenger = binding.binaryMessenger
|
||||
channel = MethodChannel(messenger, SERVICE_CHANNEL)
|
||||
channel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/service")
|
||||
channel.setMethodCallHandler(this)
|
||||
// Serial: watchRoute keeps the latest intent by the order calls reach the Core.
|
||||
coreChannel = MethodChannel(
|
||||
messenger,
|
||||
CORE_CHANNEL,
|
||||
StandardMethodCodec.INSTANCE,
|
||||
messenger.makeBackgroundTaskQueue(),
|
||||
)
|
||||
coreChannel.setMethodCallHandler(::onCoreMethodCall)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
coreChannel.setMethodCallHandler(null)
|
||||
scope.cancel()
|
||||
ServiceController.setEventListener(null)
|
||||
}
|
||||
@@ -50,17 +35,11 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
when (call.method) {
|
||||
"init" -> initialize(result)
|
||||
"shutdown" -> shutdown(result)
|
||||
"invokeMethod" -> invokeMethod(call, result)
|
||||
"getRunTime" -> getRunTime(result)
|
||||
"syncState" -> syncState(call, result)
|
||||
"start" -> start(call, result)
|
||||
"stop" -> stop(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCoreMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"invokeMethod" -> invokeMethod(call, result)
|
||||
"start" -> start(result)
|
||||
"stop" -> stop(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -72,8 +51,10 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
}
|
||||
|
||||
private fun shutdown(result: MethodChannel.Result) {
|
||||
ServiceController.setEventListener(null)
|
||||
result.success(true)
|
||||
scope.launch {
|
||||
ServiceController.unbind()
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeMethod(call: MethodCall, result: MethodChannel.Result) {
|
||||
@@ -82,16 +63,19 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
result.error("INVALID_ARGUMENT", "Method call payload must be a string", null)
|
||||
return
|
||||
}
|
||||
ServiceController.invokeMethod(data) { response ->
|
||||
result.success(response)
|
||||
}.onFailure { error ->
|
||||
result.error("CORE_ERROR", error.message, null)
|
||||
scope.launch {
|
||||
ServiceController.invokeMethod(data) { response ->
|
||||
result.success(response)
|
||||
}.onFailure { error ->
|
||||
result.error("CORE_ERROR", error.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRunTime(result: MethodChannel.Result) {
|
||||
scope.launch {
|
||||
result.success(ServiceState.awaitRunTime())
|
||||
ServiceState.refresh()
|
||||
result.success(ServiceState.runTimeMillis)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,36 +94,19 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private fun start(call: MethodCall, result: MethodChannel.Result) {
|
||||
commandRevision = call.arguments as? Int ?: commandRevision
|
||||
private fun start(result: MethodChannel.Result) {
|
||||
ServiceState.requestStart()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun stop(call: MethodCall, result: MethodChannel.Result) {
|
||||
commandRevision = call.arguments as? Int ?: commandRevision
|
||||
private fun stop(result: MethodChannel.Result) {
|
||||
ServiceState.requestStop()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
// Read on the platform thread, where start and stop arrive: revision and intent are one instant.
|
||||
fun notifyStopped() {
|
||||
private fun sendEvent(value: String?) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
if (!ServiceState.isRunRequested) {
|
||||
channel.invokeMethod("stopped", commandRevision)
|
||||
}
|
||||
channel.invokeMethod("event", value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendEvent(value: ByteArray) {
|
||||
val message = StandardMethodCodec.INSTANCE.encodeMethodCall(MethodCall("event", value))
|
||||
scope.launch(Dispatchers.Main) {
|
||||
messenger.send(SERVICE_CHANNEL, message)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SERVICE_CHANNEL = "${Components.PACKAGE_NAME}/service"
|
||||
val CORE_CHANNEL = "${Components.PACKAGE_NAME}/core"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
channel.invokeMethodOnMainThread("start")
|
||||
}
|
||||
|
||||
fun handleStop() {
|
||||
channel.invokeMethodOnMainThread("stop")
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.notImplemented()
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="240"
|
||||
android:viewportHeight="240">
|
||||
<group
|
||||
android:scaleX="0.9"
|
||||
android:scaleY="0.9"
|
||||
android:translateX="15.23"
|
||||
android:translateY="12.22">
|
||||
<path
|
||||
android:fillColor="#6666FB"
|
||||
android:pathData="M60.65,89.6L154.18,35.6A18,18 107.59,0 1,178.77 42.19L178.77,42.19A18,18 107.59,0 1,172.18 66.78L78.65,120.78A18,18 106.67,0 1,54.06 114.19L54.06,114.19A18,18 106.67,0 1,60.65 89.6z" />
|
||||
<path
|
||||
android:fillColor="#336AB6"
|
||||
android:pathData="M84.65,131.17L131.42,104.17A18,18 107.83,0 1,156 110.76L156,110.76A18,18 107.83,0 1,149.42 135.35L102.65,162.35A18,18 106.67,0 1,78.06 155.76L78.06,155.76A18,18 106.67,0 1,84.65 131.17z" />
|
||||
<path
|
||||
android:fillColor="#5CA8E9"
|
||||
android:pathData="M108.65,172.74L108.65,172.74A18,18 116.03,0 1,133.24 179.33L133.24,179.33A18,18 116.03,0 1,126.65 203.92L126.65,203.92A18,18 116.03,0 1,102.06 197.33L102.06,197.33A18,18 116.03,0 1,108.65 172.74z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@color/shortcut_background"
|
||||
android:pathData="M24,2 A22,22 0 1 1 24,46 A22,22 0 1 1 24,2 Z" />
|
||||
<group
|
||||
android:scaleX="1.35"
|
||||
android:scaleY="1.35"
|
||||
android:translateX="7.8"
|
||||
android:translateY="7.8">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M8.30,7.74 A1.2,1.2 0 0 1 10.12,6.71 L17.12,10.98 A1.2,1.2 0 0 1 17.12,13.02 L10.12,17.29 A1.2,1.2 0 0 1 8.30,16.26 Z"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@color/shortcut_background"
|
||||
android:pathData="M24,2 A22,22 0 1 1 24,46 A22,22 0 1 1 24,2 Z" />
|
||||
<group
|
||||
android:scaleX="1.35"
|
||||
android:scaleY="1.35"
|
||||
android:translateX="7.8"
|
||||
android:translateY="7.8">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14.90,7.00 A1.1,1.1 0 0 1 16.00,5.90 L16.00,5.90 A1.1,1.1 0 0 1 17.10,7.00 L17.10,17.00 A1.1,1.1 0 0 1 16.00,18.10 L16.00,18.10 A1.1,1.1 0 0 1 14.90,17.00 Z M6.90,7.00 A1.1,1.1 0 0 1 8.00,5.90 L8.00,5.90 A1.1,1.1 0 0 1 9.10,7.00 L9.10,17.00 A1.1,1.1 0 0 1 8.00,18.10 L8.00,18.10 A1.1,1.1 0 0 1 6.90,17.00 Z"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@color/shortcut_background"
|
||||
android:pathData="M24,2 A22,22 0 1 1 24,46 A22,22 0 1 1 24,2 Z" />
|
||||
<group
|
||||
android:scaleX="1.35"
|
||||
android:scaleY="1.35"
|
||||
android:translateX="7.8"
|
||||
android:translateY="7.8">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M2.90,12.00 A5.5,5.5 0 0 1 8.40,6.50 L15.60,6.50 A5.5,5.5 0 0 1 21.10,12.00 L21.10,12.00 A5.5,5.5 0 0 1 15.60,17.50 L8.40,17.50 A5.5,5.5 0 0 1 2.90,12.00 Z"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8" />
|
||||
<path
|
||||
android:fillColor="@color/shortcut_background"
|
||||
android:pathData="M8.4,9.2 A2.8,2.8 0 1 1 8.4,14.8 A2.8,2.8 0 1 1 8.4,9.2 Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground_tv" />
|
||||
</adaptive-icon>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 782 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="shortcut_background">#6666FB</color>
|
||||
</resources>
|
||||
@@ -14,9 +14,6 @@ rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,6 @@ 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 {
|
||||
@@ -32,5 +27,7 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.gson)
|
||||
testImplementation(libs.junit)
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.crashlytics.ndk)
|
||||
implementation(libs.firebase.analytics)
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ enum class QuickAction {
|
||||
}
|
||||
|
||||
enum class BroadcastAction {
|
||||
VPN_START_REQUESTED,
|
||||
VPN_REVOKED,
|
||||
SERVICE_CREATED,
|
||||
SERVICE_DESTROYED,
|
||||
}
|
||||
|
||||
enum class AccessControlMode {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.ActivityManager
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -9,12 +10,12 @@ import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Context.RECEIVER_EXPORTED
|
||||
import android.content.Context.RECEIVER_NOT_EXPORTED
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
@@ -31,23 +32,27 @@ val ComponentName.intent: Intent
|
||||
val QuickAction.action: String
|
||||
get() = "${GlobalState.application.packageName}.action.${this.name}"
|
||||
|
||||
val QuickAction.shortcutId: String
|
||||
get() = name.lowercase()
|
||||
|
||||
val QuickAction.quickIntent: Intent
|
||||
get() = Components.quickActionActivity.intent.apply {
|
||||
action = this@quickIntent.action
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
val BroadcastAction.action: String
|
||||
get() = "${GlobalState.application.packageName}.intent.action.${this.name}"
|
||||
|
||||
val Context.processName: String?
|
||||
get() = getSystemService<ActivityManager>()
|
||||
?.runningAppProcesses
|
||||
?.firstOrNull { it.pid == android.os.Process.myPid() }
|
||||
?.processName
|
||||
|
||||
fun BroadcastAction.sendBroadcast() {
|
||||
val broadcastAction = action
|
||||
val intent = Intent(broadcastAction).apply {
|
||||
component = Components.serviceBroadcastReceiver
|
||||
}
|
||||
GlobalState.log("Send broadcast: $broadcastAction")
|
||||
GlobalState.application.sendBroadcast(
|
||||
intent,
|
||||
GlobalState.receiveBroadcastPermission,
|
||||
@@ -62,24 +67,19 @@ val Intent.toPendingIntent: PendingIntent
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
GlobalState.NOTIFICATION_ID,
|
||||
@@ -95,19 +95,13 @@ fun Service.startForeground(notification: Notification) {
|
||||
fun Context.registerReceiverCompat(
|
||||
receiver: BroadcastReceiver,
|
||||
filter: IntentFilter,
|
||||
exported: Boolean = false,
|
||||
) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(
|
||||
receiver,
|
||||
filter,
|
||||
if (exported) RECEIVER_EXPORTED else RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
registerReceiver(receiver, filter)
|
||||
}
|
||||
|
||||
fun Context.receiveBroadcastFlow(
|
||||
exported: Boolean = false,
|
||||
configure: IntentFilter.() -> Unit,
|
||||
): Flow<Intent> = callbackFlow {
|
||||
val filter = IntentFilter().apply(configure)
|
||||
@@ -117,6 +111,6 @@ fun Context.receiveBroadcastFlow(
|
||||
trySend(intent)
|
||||
}
|
||||
}
|
||||
registerReceiverCompat(receiver, filter, exported)
|
||||
registerReceiverCompat(receiver, filter)
|
||||
awaitClose { unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -11,8 +11,6 @@ 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
|
||||
@@ -34,20 +32,16 @@ object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatch
|
||||
Log.d("FlClash", text)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
fun setCrashlytics(enable: Boolean) {
|
||||
FirebaseApp.initializeApp(application)
|
||||
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
|
||||
if (enable) {
|
||||
log("Crashlytics enabled for ${application.processName}")
|
||||
}
|
||||
}
|
||||
|
||||
fun didCrashOnPreviousExecution(): Boolean {
|
||||
FirebaseApp.initializeApp(application)
|
||||
return FirebaseCrashlytics.getInstance().didCrashOnPreviousExecution()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
@@ -17,13 +11,20 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -32,244 +33,6 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
val gomobileTargetByPlatform =
|
||||
linkedMapOf("android-arm" to "android/arm", "android-arm64" to "android/arm64", "android-x64" to "android/amd64")
|
||||
val gomobileTargets =
|
||||
(rootProject.findProperty("target-platform") as String?)
|
||||
?.split(",")
|
||||
?.map { gomobileTargetByPlatform[it.trim()] ?: throw GradleException("No Core for Flutter target platform $it") }
|
||||
?: gomobileTargetByPlatform.values.toList()
|
||||
|
||||
val isWindows = System.getProperty("os.name").startsWith("Windows")
|
||||
|
||||
// Gradle started from Android Studio sees the PATH of the GUI session, which leaves out Homebrew and the Go installer.
|
||||
val goExecutablePath =
|
||||
providers.provider {
|
||||
val name = if (isWindows) "go.exe" else "go"
|
||||
val home = System.getProperty("user.home")
|
||||
val searchPath =
|
||||
System.getenv("PATH").orEmpty().split(File.pathSeparator) +
|
||||
listOf("/opt/homebrew/bin", "/usr/local/bin", "/usr/local/go/bin", "$home/go/bin")
|
||||
searchPath
|
||||
.filter { it.isNotEmpty() }
|
||||
.map { File(it, name) }
|
||||
.firstOrNull { it.canExecute() }
|
||||
?.path
|
||||
?: throw GradleException("The Android Core is built with gomobile, which needs Go on PATH")
|
||||
}
|
||||
|
||||
val goModule = rootProject.layout.projectDirectory.dir("../core")
|
||||
|
||||
// gomobile names the library gojni, both as the file it builds and in go.Seq's System.loadLibrary, and has no option to change it.
|
||||
val coreLibraryName = "clash"
|
||||
|
||||
val buildConfig =
|
||||
providers.fileContents(rootProject.layout.projectDirectory.file("../build_config.yaml")).asText.map { text ->
|
||||
text.lines()
|
||||
.mapNotNull { Regex("""^(\w+):\s*(.*?)\s*$""").find(it)?.destructured }
|
||||
.associate { (key, value) -> key to value.trim('"', '\'') }
|
||||
}
|
||||
|
||||
abstract class GomobileBind : DefaultTask() {
|
||||
@get:Inject
|
||||
abstract val execOperations: ExecOperations
|
||||
|
||||
@get:InputFiles
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val sources: ConfigurableFileCollection
|
||||
|
||||
@get:Internal
|
||||
abstract val moduleDirectory: DirectoryProperty
|
||||
|
||||
@get:Internal
|
||||
abstract val goExecutable: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val goVersion: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val targets: ListProperty<String>
|
||||
|
||||
@get:Input
|
||||
abstract val androidApi: Property<Int>
|
||||
|
||||
@get:Input
|
||||
abstract val tags: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val ldflags: Property<String>
|
||||
|
||||
@get:Internal
|
||||
abstract val sdkDirectory: DirectoryProperty
|
||||
|
||||
@get:Internal
|
||||
abstract val ndkDirectory: DirectoryProperty
|
||||
|
||||
@get:Input
|
||||
abstract val ndkVersion: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val libraryName: Property<String>
|
||||
|
||||
@get:OutputFile
|
||||
abstract val aar: RegularFileProperty
|
||||
|
||||
@get:OutputFile
|
||||
abstract val classesJar: RegularFileProperty
|
||||
|
||||
@TaskAction
|
||||
fun bind() {
|
||||
val go = goExecutable.get()
|
||||
val tools = temporaryDir.resolve("bin")
|
||||
val executableSuffix = if (System.getProperty("os.name").startsWith("Windows")) ".exe" else ""
|
||||
tools.mkdirs()
|
||||
val path =
|
||||
listOf(tools, File(System.getProperty("java.home"), "bin"), File(go).parentFile)
|
||||
.joinToString(File.pathSeparator) { it.path } + File.pathSeparator + System.getenv("PATH").orEmpty()
|
||||
execOperations.exec {
|
||||
workingDir(moduleDirectory)
|
||||
environment("PATH", path)
|
||||
commandLine(go, "build", "-o", tools.path + File.separator, "golang.org/x/mobile/cmd/gomobile", "golang.org/x/mobile/cmd/gobind")
|
||||
}
|
||||
// gomobile links the library as the main package of a module it generates, whose go line is the toolchain's,
|
||||
// so the GODEBUG defaults that the go line of core/go.mod sets for the desktop Core would not reach it on their own.
|
||||
val godebug = ByteArrayOutputStream()
|
||||
execOperations.exec {
|
||||
workingDir(moduleDirectory)
|
||||
environment("PATH", path)
|
||||
commandLine(go, "list", "-f", "{{.DefaultGODEBUG}}", "./cmd/core")
|
||||
standardOutput = godebug
|
||||
}
|
||||
execOperations.exec {
|
||||
workingDir(moduleDirectory)
|
||||
environment("PATH", path)
|
||||
environment("ANDROID_HOME", sdkDirectory.get().asFile.path)
|
||||
environment("ANDROID_NDK_HOME", ndkDirectory.get().asFile.path)
|
||||
commandLine(
|
||||
tools.resolve("gomobile$executableSuffix").path,
|
||||
"bind",
|
||||
"-target=${targets.get().joinToString(",")}",
|
||||
"-androidapi=${androidApi.get()}",
|
||||
"-javapkg=com.follow.clash.core",
|
||||
"-tags=${tags.get()}",
|
||||
"-ldflags=${ldflags.get()} -X=runtime.godebugDefault=${godebug.toString().trim()}",
|
||||
"-o",
|
||||
aar.get().asFile.path,
|
||||
"./mobile",
|
||||
)
|
||||
}
|
||||
ZipFile(aar.get().asFile).use { aarFile ->
|
||||
ZipInputStream(aarFile.getInputStream(aarFile.getEntry("classes.jar"))).use { jar ->
|
||||
ZipOutputStream(classesJar.get().asFile.outputStream()).use { out ->
|
||||
var seqPatched = false
|
||||
generateSequence { jar.nextEntry }.forEach { entry ->
|
||||
var bytes = jar.readBytes()
|
||||
if (entry.name == "go/Seq.class") {
|
||||
bytes = bytes.replaceUtf8Constant("gojni", libraryName.get())
|
||||
seqPatched = true
|
||||
}
|
||||
out.putNextEntry(ZipEntry(entry.name).apply { time = entry.time })
|
||||
out.write(bytes)
|
||||
}
|
||||
if (!seqPatched) throw GradleException("gomobile's classes.jar has no go/Seq.class to load lib${libraryName.get()}.so")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.replaceUtf8Constant(old: String, new: String): ByteArray {
|
||||
fun constant(value: String) = value.toByteArray().let { byteArrayOf(1, (it.size shr 8).toByte(), it.size.toByte()) + it }
|
||||
val target = constant(old)
|
||||
val matches = (0..size - target.size).filter { start -> target.indices.all { this[start + it] == target[it] } }
|
||||
if (matches.size != 1) throw GradleException("Expected one \"$old\" constant in go/Seq.class, found ${matches.size}")
|
||||
val start = matches.single()
|
||||
return copyOfRange(0, start) + constant(new) + copyOfRange(start + target.size, size)
|
||||
}
|
||||
}
|
||||
|
||||
abstract class UnpackJniLibs : DefaultTask() {
|
||||
@get:Inject
|
||||
abstract val archiveOperations: ArchiveOperations
|
||||
|
||||
@get:Inject
|
||||
abstract val fileSystemOperations: FileSystemOperations
|
||||
|
||||
@get:InputFile
|
||||
@get:PathSensitive(PathSensitivity.NONE)
|
||||
abstract val aar: RegularFileProperty
|
||||
|
||||
@get:Input
|
||||
abstract val libraryName: Property<String>
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDirectory: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun unpack() {
|
||||
var renamed = 0
|
||||
fileSystemOperations.sync {
|
||||
from(archiveOperations.zipTree(aar)) {
|
||||
include("jni/**")
|
||||
eachFile {
|
||||
path = path.removePrefix("jni/")
|
||||
if (name == "libgojni.so") {
|
||||
name = "lib${libraryName.get()}.so"
|
||||
renamed++
|
||||
}
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
}
|
||||
into(outputDirectory)
|
||||
}
|
||||
if (renamed == 0) throw GradleException("gomobile's AAR has no libgojni.so to rename")
|
||||
}
|
||||
}
|
||||
|
||||
val bindCore =
|
||||
tasks.register<GomobileBind>("bindCore") {
|
||||
sources.from(
|
||||
fileTree(goModule) {
|
||||
exclude("**/*_test.go", "**/testdata/**", "cmd/**", "Clash.Meta/.github/**")
|
||||
},
|
||||
)
|
||||
moduleDirectory.set(goModule)
|
||||
goExecutable.set(goExecutablePath)
|
||||
goVersion.set(
|
||||
goExecutablePath
|
||||
.flatMap { go -> providers.exec { commandLine(go, "env", "GOVERSION") }.standardOutput.asText }
|
||||
.map { it.trim() },
|
||||
)
|
||||
targets.set(gomobileTargets)
|
||||
androidApi.set(libs.versions.minSdk.get().toInt())
|
||||
tags.set(buildConfig.map { it["tags"].orEmpty() })
|
||||
ldflags.set(
|
||||
buildConfig.map { config ->
|
||||
val flags = config["go_ldflags"].orEmpty().split(" ").filter { it.isNotEmpty() }
|
||||
val version = config["core_version"].orEmpty()
|
||||
(flags + listOfNotNull(version.takeIf { it.isNotEmpty() }?.let { "-X=github.com/metacubex/mihomo/constant.Version=$it" }))
|
||||
.joinToString(" ")
|
||||
},
|
||||
)
|
||||
sdkDirectory.set(androidComponents.sdkComponents.sdkDirectory)
|
||||
ndkDirectory.set(androidComponents.sdkComponents.ndkDirectory)
|
||||
ndkVersion.set(libs.versions.ndkVersion)
|
||||
libraryName.set(coreLibraryName)
|
||||
aar.set(layout.buildDirectory.file("gomobile/core.aar"))
|
||||
classesJar.set(layout.buildDirectory.file("gomobile/classes.jar"))
|
||||
}
|
||||
|
||||
// A library cannot bundle a local AAR into its own, so the gomobile AAR reaches AGP as a classes jar and generated jniLibs.
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
val unpack =
|
||||
tasks.register<UnpackJniLibs>("unpack${variant.name.replaceFirstChar { it.uppercase() }}CoreJniLibs") {
|
||||
aar.set(bindCore.flatMap { it.aar })
|
||||
libraryName.set(coreLibraryName)
|
||||
}
|
||||
variant.sources.jniLibs?.addGeneratedSourceDirectory(unpack, UnpackJniLibs::outputDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(files(bindCore.flatMap { it.classesJar }))
|
||||
implementation(libs.annotation.jvm)
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-keep class go.** { *; }
|
||||
-keep class com.follow.clash.core.mobile.** { *; }
|
||||
@@ -0,0 +1,38 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
|
||||
project("core")
|
||||
|
||||
message("CMAKE_SOURCE_DIR ${CMAKE_SOURCE_DIR}")
|
||||
|
||||
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)
|
||||
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 ()
|
||||
@@ -0,0 +1,222 @@
|
||||
#include <jni.h>
|
||||
|
||||
#ifdef LIBCLASH
|
||||
|
||||
#include "jni_helper.h"
|
||||
#include "libclash.h"
|
||||
#include "bride.h"
|
||||
|
||||
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) {
|
||||
const auto interface = new_global(cb);
|
||||
startTUN(interface, fd, get_string(stack), get_string(address), get_string(dns));
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
|
||||
stopTun();
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_forceGC(JNIEnv *env, jobject thiz) {
|
||||
forceGC();
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns) {
|
||||
updateDns(get_string(dns));
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
|
||||
const auto interface = new_global(cb);
|
||||
invokeMethod(interface, get_string(data));
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_setEventListener(JNIEnv *env, jobject thiz, jobject cb) {
|
||||
if (cb != nullptr) {
|
||||
const auto interface = new_global(cb);
|
||||
setEventListener(interface);
|
||||
} else {
|
||||
setEventListener(nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
scoped_string traffic = getTraffic(only_statistics_proxy);
|
||||
return new_string(traffic);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
scoped_string traffic = getTotalTraffic(only_statistics_proxy);
|
||||
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,
|
||||
jstring setup_params_string, jobject cb) {
|
||||
const auto interface = new_global(cb);
|
||||
quickSetup(interface, get_string(init_params_string), get_string(setup_params_string));
|
||||
}
|
||||
|
||||
|
||||
static jmethodID m_tun_interface_protect;
|
||||
static jmethodID m_tun_interface_resolve_process;
|
||||
static jmethodID m_invoke_interface_result;
|
||||
|
||||
|
||||
static void release_jni_object_impl(void *obj) {
|
||||
ATTACH_JNI();
|
||||
del_global(static_cast<jobject>(obj));
|
||||
}
|
||||
|
||||
static void free_string_impl(char *str) {
|
||||
free(str);
|
||||
}
|
||||
|
||||
static void call_tun_interface_protect_impl(void *tun_interface, const int fd) {
|
||||
ATTACH_JNI();
|
||||
env->CallVoidMethod(static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_protect,
|
||||
fd);
|
||||
}
|
||||
|
||||
static char *
|
||||
call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
|
||||
const char *source,
|
||||
const char *target,
|
||||
const int uid) {
|
||||
ATTACH_JNI();
|
||||
const auto source_string = new_string(source);
|
||||
const auto target_string = new_string(target);
|
||||
const auto package_name = reinterpret_cast<jstring>(env->CallObjectMethod(
|
||||
static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_resolve_process,
|
||||
protocol,
|
||||
source_string,
|
||||
target_string,
|
||||
uid));
|
||||
env->DeleteLocalRef(source_string);
|
||||
env->DeleteLocalRef(target_string);
|
||||
const auto result = get_string(package_name);
|
||||
if (package_name != nullptr) {
|
||||
env->DeleteLocalRef(package_name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
|
||||
ATTACH_JNI();
|
||||
const auto value = new_string(data);
|
||||
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
|
||||
m_invoke_interface_result,
|
||||
value);
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jint JNICALL
|
||||
JNI_OnLoad(JavaVM *vm, void *) {
|
||||
JNIEnv *env = nullptr;
|
||||
if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
|
||||
return JNI_ERR;
|
||||
}
|
||||
|
||||
initialize_jni(vm, env);
|
||||
|
||||
const auto c_tun_interface = find_class("com/follow/clash/core/TunInterface");
|
||||
|
||||
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_invoke_interface_result = find_method(c_invoke_interface, "onResult",
|
||||
"(Ljava/lang/String;)V");
|
||||
|
||||
|
||||
protect_func = &call_tun_interface_protect_impl;
|
||||
resolve_process_func = &call_tun_interface_resolve_process_impl;
|
||||
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
|
||||
@@ -0,0 +1,61 @@
|
||||
#include "jni_helper.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
static JavaVM *global_vm;
|
||||
|
||||
static jclass c_string;
|
||||
static jmethodID m_new_string;
|
||||
static jmethodID m_get_bytes;
|
||||
|
||||
void initialize_jni(JavaVM *vm, JNIEnv *env) {
|
||||
global_vm = vm;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
char *jni_get_string(JNIEnv *env, jstring str) {
|
||||
if (str == nullptr) {
|
||||
return static_cast<char *>(calloc(1, 1));
|
||||
}
|
||||
const auto array = reinterpret_cast<jbyteArray>(env->CallObjectMethod(str, m_get_bytes));
|
||||
const int length = env->GetArrayLength(array);
|
||||
const auto content = static_cast<char *>(malloc(length + 1));
|
||||
env->GetByteArrayRegion(array, 0, length, reinterpret_cast<jbyte *>(content));
|
||||
env->DeleteLocalRef(array);
|
||||
content[length] = 0;
|
||||
return content;
|
||||
}
|
||||
|
||||
jstring jni_new_string(JNIEnv *env, const char *str) {
|
||||
const auto length = static_cast<int>(strlen(str));
|
||||
const auto array = env->NewByteArray(length);
|
||||
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
|
||||
const auto result = reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
|
||||
env->DeleteLocalRef(array);
|
||||
return result;
|
||||
}
|
||||
|
||||
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) {
|
||||
abort();
|
||||
}
|
||||
jni->require_release = 1;
|
||||
}
|
||||
|
||||
void jni_detach_thread(const scoped_jni *env) {
|
||||
if (env->require_release) {
|
||||
global_vm->DetachCurrentThread();
|
||||
}
|
||||
}
|
||||
|
||||
void release_string(char **value) {
|
||||
free(*value);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
struct scoped_jni {
|
||||
JNIEnv *env;
|
||||
int require_release;
|
||||
};
|
||||
|
||||
extern void initialize_jni(JavaVM *vm, JNIEnv *env);
|
||||
|
||||
extern jstring jni_new_string(JNIEnv *env, const char *str);
|
||||
|
||||
extern char *jni_get_string(JNIEnv *env, jstring str);
|
||||
|
||||
extern void jni_attach_thread(scoped_jni *jni);
|
||||
|
||||
extern void jni_detach_thread(const scoped_jni *env);
|
||||
|
||||
extern void release_string(char **value);
|
||||
|
||||
#define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \
|
||||
scoped_jni _jni{}; \
|
||||
jni_attach_thread(&_jni); \
|
||||
JNIEnv *env = _jni.env
|
||||
|
||||
#define scoped_string __attribute__((cleanup(release_string))) char*
|
||||
|
||||
#define find_class(name) env->FindClass(name)
|
||||
#define find_method(cls, name, signature) env->GetMethodID(cls, name, signature)
|
||||
#define new_global(obj) env->NewGlobalRef(obj)
|
||||
#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)
|
||||
@@ -1,20 +1,23 @@
|
||||
package com.follow.clash.core
|
||||
|
||||
import com.follow.clash.core.mobile.Mobile
|
||||
import com.follow.clash.core.mobile.ResultHandler
|
||||
import com.follow.clash.core.mobile.TunHandler
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URI
|
||||
|
||||
object Core {
|
||||
fun forceGC() = Mobile.forceGC()
|
||||
|
||||
fun updateDNS(
|
||||
private external fun startTun(
|
||||
fd: Int,
|
||||
cb: TunInterface,
|
||||
stack: String,
|
||||
address: String,
|
||||
dns: String,
|
||||
) = Mobile.updateDns(dns)
|
||||
)
|
||||
|
||||
fun resetNetwork() = Mobile.resetNetwork()
|
||||
external fun forceGC()
|
||||
|
||||
external fun updateDNS(
|
||||
dns: String,
|
||||
)
|
||||
|
||||
private fun parseInetSocketAddress(address: String): InetSocketAddress {
|
||||
val uri = URI("tcp://$address")
|
||||
@@ -26,52 +29,77 @@ object Core {
|
||||
fun startTun(
|
||||
fd: Int,
|
||||
protect: (Int) -> Boolean,
|
||||
resolveUid: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress) -> Int,
|
||||
resolvePackage: (uid: Int) -> String,
|
||||
resolverProcess: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress, uid: Int) -> String,
|
||||
stack: String,
|
||||
address: String,
|
||||
dns: String,
|
||||
options: String,
|
||||
): Boolean {
|
||||
return Mobile.startTun(
|
||||
) {
|
||||
startTun(
|
||||
fd,
|
||||
object : TunHandler {
|
||||
override fun protect(fd: Int): Boolean = protect(fd)
|
||||
object : TunInterface {
|
||||
override fun protect(fd: Int) {
|
||||
protect(fd)
|
||||
}
|
||||
|
||||
override fun resolveUid(
|
||||
override fun resolverProcess(
|
||||
protocol: Int,
|
||||
source: String,
|
||||
target: String,
|
||||
): Int {
|
||||
return resolveUid(
|
||||
uid: Int,
|
||||
): String {
|
||||
return resolverProcess(
|
||||
protocol,
|
||||
parseInetSocketAddress(source),
|
||||
parseInetSocketAddress(target),
|
||||
uid,
|
||||
)
|
||||
}
|
||||
|
||||
override fun resolvePackage(uid: Int): String = resolvePackage(uid)
|
||||
},
|
||||
stack,
|
||||
address,
|
||||
dns,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
external fun suspended(
|
||||
suspended: Boolean,
|
||||
)
|
||||
|
||||
private external fun invokeMethod(
|
||||
data: String,
|
||||
cb: InvokeInterface,
|
||||
)
|
||||
|
||||
fun invokeMethod(
|
||||
data: String,
|
||||
cb: (result: ByteArray?) -> Unit,
|
||||
cb: (result: String?) -> Unit,
|
||||
) {
|
||||
Mobile.invokeMethod(data, ResultHandler { result -> cb(result) })
|
||||
invokeMethod(
|
||||
data,
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
cb(result)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private external fun setEventListener(cb: InvokeInterface?)
|
||||
|
||||
fun updateEventListener(
|
||||
callback: ((result: ByteArray) -> Unit)?,
|
||||
callback: ((result: String?) -> Unit)?,
|
||||
) {
|
||||
Mobile.setEventListener(
|
||||
callback?.let { ResultHandler { result -> it(result ?: ByteArray(0)) } },
|
||||
)
|
||||
if (callback == null) {
|
||||
setEventListener(null)
|
||||
} else {
|
||||
setEventListener(
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
callback(result)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun quickSetup(
|
||||
@@ -79,20 +107,30 @@ object Core {
|
||||
setupParamsString: String,
|
||||
callback: (result: String?) -> Unit,
|
||||
) {
|
||||
Mobile.quickSetup(
|
||||
quickSetup(
|
||||
initParamsString,
|
||||
setupParamsString,
|
||||
ResultHandler { result -> callback(result?.decodeToString()) },
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
callback(result)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun stopTun() = Mobile.stopTun()
|
||||
private external fun quickSetup(
|
||||
initParamsString: String,
|
||||
setupParamsString: String,
|
||||
cb: InvokeInterface,
|
||||
)
|
||||
|
||||
fun startListener() = Mobile.startListener()
|
||||
external fun stopTun()
|
||||
|
||||
fun stopListener() = Mobile.stopListener()
|
||||
external fun getTraffic(onlyStatisticsProxy: Boolean): String
|
||||
|
||||
fun getTraffic(onlyStatisticsProxy: Boolean): String = Mobile.getTraffic(onlyStatisticsProxy)
|
||||
external fun getTotalTraffic(onlyStatisticsProxy: Boolean): String
|
||||
|
||||
fun getTotalTraffic(onlyStatisticsProxy: Boolean): String = Mobile.getTotalTraffic(onlyStatisticsProxy)
|
||||
init {
|
||||
System.loadLibrary("core")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.follow.clash.core
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
interface InvokeInterface {
|
||||
fun onResult(result: String?)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.follow.clash.core
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
interface TunInterface {
|
||||
fun protect(fd: Int)
|
||||
|
||||
fun resolverProcess(protocol: Int, source: String, target: String, uid: Int): String
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
[versions]
|
||||
minSdk = "24"
|
||||
targetSdk = "37"
|
||||
compileSdk = "37"
|
||||
firebaseBom = "34.15.0"
|
||||
minSdk = "23"
|
||||
targetSdk = "36"
|
||||
compileSdk = "36"
|
||||
ndkVersion = "28.2.13676358"
|
||||
coreKtx = "1.19.1"
|
||||
coreSplashscreen = "1.2.0"
|
||||
gson = "2.14.0"
|
||||
smaliDexlib2 = "3.0.10"
|
||||
junit = "4.13.2"
|
||||
kotlinxCoroutines = "1.11.0"
|
||||
coreKtx = "1.17.0"
|
||||
annotationJvm = "1.9.1"
|
||||
coreSplashscreen = "1.0.1"
|
||||
gson = "2.13.1"
|
||||
smaliDexlib2 = "3.0.9"
|
||||
|
||||
[libraries]
|
||||
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||
annotation-jvm = { module = "androidx.annotation:annotation-jvm", version.ref = "annotationJvm" }
|
||||
core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
||||
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.8.0-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
|
||||
|
||||
|
||||
@@ -17,10 +17,6 @@ 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 {
|
||||
@@ -34,6 +30,4 @@ dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation(libs.gson)
|
||||
implementation(libs.androidx.core)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
@@ -51,25 +51,10 @@ class FilesProvider : DocumentsProvider() {
|
||||
mode: String,
|
||||
signal: CancellationSignal?,
|
||||
): ParcelFileDescriptor {
|
||||
val accessMode = ParcelFileDescriptor.parseMode(canonicalMode(mode))
|
||||
val accessMode = ParcelFileDescriptor.parseMode(mode)
|
||||
return ParcelFileDescriptor.open(resolveFile(documentId), accessMode)
|
||||
}
|
||||
|
||||
// parseMode throws on a letter outside r, w, t and a, and before Android 10
|
||||
// on any spelling but these six; callers do not all stick to them.
|
||||
private fun canonicalMode(mode: String): String {
|
||||
val read = 'r' in mode
|
||||
val truncate = 't' in mode
|
||||
return when {
|
||||
'w' !in mode -> "r"
|
||||
read && truncate -> "rwt"
|
||||
read -> "rw"
|
||||
truncate -> "wt"
|
||||
'a' in mode -> "wa"
|
||||
else -> "w"
|
||||
}
|
||||
}
|
||||
|
||||
private fun includeFile(result: MatrixCursor, file: File) {
|
||||
result.newRow().apply {
|
||||
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, file.absolutePath)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package com.follow.clash.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.ComponentCallbacks2
|
||||
import com.follow.clash.common.BroadcastAction
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.sendBroadcast
|
||||
import com.follow.clash.core.Core
|
||||
|
||||
interface ManagedService {
|
||||
fun start()
|
||||
@@ -13,23 +11,12 @@ interface ManagedService {
|
||||
fun stop()
|
||||
}
|
||||
|
||||
internal fun Service.notifyVpnStartRequested() {
|
||||
GlobalState.log("VPN start requested")
|
||||
BroadcastAction.VPN_START_REQUESTED.sendBroadcast()
|
||||
internal fun Service.notifyCreated() {
|
||||
GlobalState.log("Service created")
|
||||
BroadcastAction.SERVICE_CREATED.sendBroadcast()
|
||||
}
|
||||
|
||||
internal fun Service.notifyVpnRevoked() {
|
||||
GlobalState.log("VPN permission revoked")
|
||||
BroadcastAction.VPN_REVOKED.sendBroadcast()
|
||||
}
|
||||
|
||||
// UI_HIDDEN reaches the services too, since they share the activity's process,
|
||||
// and Android 14 stopped delivering the running levels this answers to.
|
||||
@Suppress("DEPRECATION")
|
||||
internal fun trimCoreMemory(level: Int) {
|
||||
if (level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW ||
|
||||
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL
|
||||
) {
|
||||
Core.forceGC()
|
||||
}
|
||||
internal fun Service.notifyDestroyed() {
|
||||
GlobalState.log("Service destroyed")
|
||||
BroadcastAction.SERVICE_DESTROYED.sendBroadcast()
|
||||
}
|
||||
|
||||
@@ -12,11 +12,9 @@ class ProxyService : Service(), ManagedService {
|
||||
private val binder = LocalBinder()
|
||||
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
cleanup()
|
||||
} finally {
|
||||
super.onDestroy()
|
||||
}
|
||||
modules.stop()
|
||||
notifyDestroyed()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onLowMemory() {
|
||||
@@ -24,11 +22,6 @@ class ProxyService : Service(), ManagedService {
|
||||
super.onLowMemory()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
trimCoreMemory(level)
|
||||
super.onTrimMemory(level)
|
||||
}
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
val service: ProxyService
|
||||
get() = this@ProxyService
|
||||
@@ -46,12 +39,7 @@ class ProxyService : Service(), ManagedService {
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
try {
|
||||
cleanup()
|
||||
} finally {
|
||||
stopSelf()
|
||||
}
|
||||
modules.stop()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun cleanup() = modules.stop()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
import com.follow.clash.common.AccessControlMode
|
||||
import com.follow.clash.common.GlobalState
|
||||
@@ -17,8 +17,6 @@ import com.follow.clash.service.models.VpnOptions
|
||||
import com.follow.clash.service.models.getIpv4RouteAddress
|
||||
import com.follow.clash.service.models.getIpv6RouteAddress
|
||||
import com.follow.clash.service.models.toCIDR
|
||||
import com.follow.clash.service.models.tunMtu
|
||||
import com.follow.clash.service.models.tunOptions
|
||||
import com.follow.clash.service.modules.ServiceModules
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -27,15 +25,10 @@ import android.net.VpnService as SystemVpnService
|
||||
class VpnService : SystemVpnService(), ManagedService {
|
||||
private val modules = ServiceModules(this)
|
||||
private val binder = LocalBinder()
|
||||
private val tunLock = Any()
|
||||
private var tunRunning = false
|
||||
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
cleanup()
|
||||
} finally {
|
||||
super.onDestroy()
|
||||
}
|
||||
modules.stop()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private val connectivity by lazy {
|
||||
@@ -43,26 +36,26 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
}
|
||||
private val uidPackageNameMap = ConcurrentHashMap<Int, String>()
|
||||
|
||||
private fun resolveUid(
|
||||
private fun resolverProcess(
|
||||
protocol: Int,
|
||||
source: InetSocketAddress,
|
||||
target: InetSocketAddress,
|
||||
): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return -1
|
||||
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()
|
||||
}
|
||||
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
|
||||
@@ -93,11 +86,6 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
super.onLowMemory()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
trimCoreMemory(level)
|
||||
super.onTrimMemory(level)
|
||||
}
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
val service: VpnService
|
||||
get() = this@VpnService
|
||||
@@ -111,22 +99,20 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// Android starts always-on VPN through this callback instead of FlClash's bound-service
|
||||
// path. Notify the app layer so it can restore Core and fully initialize the VPN service.
|
||||
notifyVpnStartRequested()
|
||||
notifyCreated()
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
stop()
|
||||
notifyVpnRevoked()
|
||||
notifyDestroyed()
|
||||
}
|
||||
|
||||
private fun handleStart(options: VpnOptions) {
|
||||
val fd = with(Builder()) {
|
||||
addAddressAndRoutes(options)
|
||||
addDnsServers(options)
|
||||
setMtu(options.tunMtu)
|
||||
setMtu(MTU)
|
||||
configureAccessControl(options)
|
||||
setSession(getString(CommonR.string.app_name))
|
||||
setBlocking(false)
|
||||
@@ -149,31 +135,14 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
establish()?.detachFd()
|
||||
?: error("VPN establishment was rejected by the system")
|
||||
}
|
||||
synchronized(tunLock) {
|
||||
tunRunning = true
|
||||
try {
|
||||
// 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,
|
||||
options = options.tunOptions,
|
||||
),
|
||||
) { "Core rejected the tun file descriptor" }
|
||||
} catch (error: Exception) {
|
||||
stopTunLocked()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Core.startTun(
|
||||
fd = fd,
|
||||
protect = this::protect,
|
||||
resolverProcess = this::resolverProcess,
|
||||
stack = options.stack,
|
||||
address = options.tunAddress,
|
||||
dns = options.tunDns,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Builder.addAddressAndRoutes(options: VpnOptions) {
|
||||
@@ -182,7 +151,7 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
addRoutes(
|
||||
routes = options::getIpv4RouteAddress,
|
||||
fallbackAddress = NET_ANY,
|
||||
routeAll = options.routeAddress.isEmpty(),
|
||||
logTag = "addRoute4",
|
||||
)
|
||||
|
||||
if (options.ipv6) {
|
||||
@@ -195,25 +164,24 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
addRoutes(
|
||||
routes = options::getIpv6RouteAddress,
|
||||
fallbackAddress = NET_ANY6,
|
||||
routeAll = options.routeAddress.isEmpty(),
|
||||
logTag = "addRoute6",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Flutter lists every family it routes, so a family missing from a
|
||||
// non-empty list is one whose routes were all excluded.
|
||||
private fun Builder.addRoutes(
|
||||
routes: () -> List<CIDR>,
|
||||
fallbackAddress: String,
|
||||
routeAll: Boolean,
|
||||
logTag: String,
|
||||
) {
|
||||
val routeList = runCatching(routes).getOrNull()
|
||||
if (routeAll || routeList == null) {
|
||||
val routeList = runCatching(routes).getOrDefault(emptyList())
|
||||
if (routeList.isEmpty()) {
|
||||
addRoute(fallbackAddress, 0)
|
||||
return
|
||||
}
|
||||
try {
|
||||
routeList.forEach { route ->
|
||||
Log.d(logTag, "address: ${route.address} prefixLength: ${route.prefixLength}")
|
||||
addRoute(route.address, route.prefixLength)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
@@ -233,28 +201,15 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
if (!accessControl.enable) return
|
||||
when (accessControl.mode) {
|
||||
AccessControlMode.ACCEPT_SELECTED -> {
|
||||
(accessControl.acceptList + packageName).forEach { name ->
|
||||
addApplication(name, ::addAllowedApplication)
|
||||
}
|
||||
(accessControl.acceptList + packageName).forEach(::addAllowedApplication)
|
||||
}
|
||||
|
||||
AccessControlMode.REJECT_SELECTED -> {
|
||||
(accessControl.rejectList - packageName).forEach { name ->
|
||||
addApplication(name, ::addDisallowedApplication)
|
||||
}
|
||||
(accessControl.rejectList - packageName).forEach(::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()
|
||||
@@ -266,41 +221,19 @@ class VpnService : SystemVpnService(), ManagedService {
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
try {
|
||||
cleanup()
|
||||
} finally {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
try {
|
||||
modules.stop()
|
||||
} finally {
|
||||
stopTun()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTun() = synchronized(tunLock) {
|
||||
stopTunLocked()
|
||||
}
|
||||
|
||||
private fun stopTunLocked() {
|
||||
if (tunRunning) {
|
||||
Core.stopTun()
|
||||
tunRunning = false
|
||||
}
|
||||
modules.stop()
|
||||
Core.stopTun()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
companion object {
|
||||
// mihomo's own TUN default: 198.18.0.0/15 is benchmarking space no LAN
|
||||
// uses, and its fake-ip pool starts at .4, past this /30.
|
||||
private const val IPV4_ADDRESS = "198.18.0.1/30"
|
||||
private const val IPV4_ADDRESS = "172.19.0.1/30"
|
||||
private const val IPV6_ADDRESS = "fdfe:dcba:9876::1/126"
|
||||
private const val DNS = "198.18.0.2"
|
||||
private const val DNS = "172.19.0.2"
|
||||
private const val DNS6 = "fdfe:dcba:9876::2"
|
||||
private const val NET_ANY = "0.0.0.0"
|
||||
private const val NET_ANY6 = "::"
|
||||
private const val LOCAL_HOST = "127.0.0.1"
|
||||
private const val MTU = 9000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,4 @@ data class NotificationParams(
|
||||
val title: String = "FlClash",
|
||||
val stopText: String = "STOP",
|
||||
val onlyStatisticsProxy: Boolean = false,
|
||||
val showStopAction: Boolean = true,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.follow.clash.service.models
|
||||
|
||||
import com.follow.clash.common.AccessControlMode
|
||||
import com.google.gson.Gson
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
@@ -24,29 +23,8 @@ data class VpnOptions(
|
||||
val bypassDomain: List<String>,
|
||||
val stack: String,
|
||||
val routeAddress: List<String>,
|
||||
val mtu: Int = 0,
|
||||
val congestionController: String? = null,
|
||||
)
|
||||
|
||||
private const val DEFAULT_TUN_MTU = 9000
|
||||
|
||||
// establish() rejects an MTU above 65535, and below 1280 the kernel drops the
|
||||
// IPv6 address.
|
||||
private val TUN_MTU_RANGE = 1280..65535
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
val VpnOptions.tunMtu: Int
|
||||
get() = if (mtu > 0) mtu.coerceIn(TUN_MTU_RANGE) else DEFAULT_TUN_MTU
|
||||
|
||||
val VpnOptions.tunOptions: String
|
||||
get() = gson.toJson(
|
||||
mapOf(
|
||||
"mtu" to tunMtu,
|
||||
"congestion-controller" to congestionController.orEmpty(),
|
||||
),
|
||||
)
|
||||
|
||||
data class CIDR(
|
||||
val address: InetAddress,
|
||||
val prefixLength: Int,
|
||||
|
||||
+11
-46
@@ -9,9 +9,6 @@ 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.HandlerThread
|
||||
import android.os.Looper
|
||||
import androidx.core.content.getSystemService
|
||||
import com.follow.clash.core.Core
|
||||
import java.net.Inet4Address
|
||||
@@ -33,9 +30,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
private val connectivity by lazy {
|
||||
service.getSystemService<ConnectivityManager>()
|
||||
}
|
||||
private val handler = Handler(networkObserveLooper)
|
||||
private var currentDnsList = listOf<String>()
|
||||
private var currentNetwork: Network? = null
|
||||
|
||||
private val request = NetworkRequest.Builder().apply {
|
||||
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
@@ -49,42 +44,25 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
networkInfos[network] = NetworkInfo()
|
||||
scheduleUpdateDns()
|
||||
updateDns()
|
||||
}
|
||||
|
||||
override fun onLosing(network: Network, maxMsToLive: Int) {
|
||||
val info = networkInfos[network] ?: return
|
||||
info.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
|
||||
scheduleUpdateDns()
|
||||
if (maxMsToLive > 0) {
|
||||
handler.postDelayed({
|
||||
if (networkInfos.containsKey(network)) {
|
||||
updateDns()
|
||||
}
|
||||
}, maxMsToLive.toLong() + 50)
|
||||
}
|
||||
networkInfos[network]?.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
|
||||
updateDns()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
networkInfos.remove(network)
|
||||
scheduleUpdateDns()
|
||||
updateDns()
|
||||
}
|
||||
|
||||
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
||||
networkInfos[network]?.dnsList = linkProperties.dnsServers
|
||||
scheduleUpdateDns()
|
||||
updateDns()
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
handler.removeCallbacks(updateDnsTask)
|
||||
handler.postDelayed(updateDnsTask, DNS_UPDATE_DELAY_MILLIS)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
updateDns()
|
||||
connectivity?.registerNetworkCallback(request, callback)
|
||||
@@ -111,44 +89,31 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
|
||||
@Synchronized
|
||||
private fun updateDns() {
|
||||
val preferred = networkInfos.entries.minByOrNull(::networkPriority)
|
||||
val dnsList = preferred
|
||||
val dnsList = networkInfos.asSequence()
|
||||
.minByOrNull(::networkPriority)
|
||||
?.value
|
||||
?.dnsList
|
||||
.orEmpty()
|
||||
.map { address -> address.asSocketAddressText(DNS_PORT) }
|
||||
.distinct()
|
||||
if (dnsList != currentDnsList) {
|
||||
currentDnsList = dnsList
|
||||
Core.updateDNS(dnsList.joinToString(","))
|
||||
if (dnsList == currentDnsList) {
|
||||
return
|
||||
}
|
||||
val network = preferred?.key ?: return
|
||||
// Sockets protected onto the network we left stay bound to it; closing
|
||||
// them makes apps reconnect now instead of after a TCP timeout.
|
||||
if (currentNetwork != null && currentNetwork != network) {
|
||||
Core.resetNetwork()
|
||||
}
|
||||
currentNetwork = network
|
||||
currentDnsList = dnsList
|
||||
Core.updateDNS(dnsList.joinToString(","))
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
try {
|
||||
connectivity?.unregisterNetworkCallback(callback)
|
||||
} finally {
|
||||
networkInfos.clear()
|
||||
updateDns()
|
||||
currentNetwork = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DNS_PORT = 53
|
||||
private const val DNS_UPDATE_DELAY_MILLIS = 200L
|
||||
|
||||
private val networkObserveLooper: Looper by lazy {
|
||||
HandlerThread("NetworkObserve").apply { start() }.looper
|
||||
}
|
||||
|
||||
private fun InetAddress.asSocketAddressText(port: Int): String = when (this) {
|
||||
is Inet6Address -> "[$hostAddress]:$port"
|
||||
|
||||
+23
-32
@@ -11,7 +11,6 @@ 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
|
||||
@@ -23,9 +22,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
|
||||
@@ -34,7 +33,6 @@ import kotlinx.coroutines.launch
|
||||
private data class ExtendedNotificationParams(
|
||||
val title: String,
|
||||
val stopText: String,
|
||||
val showStopAction: Boolean,
|
||||
val contentText: String,
|
||||
)
|
||||
|
||||
@@ -42,7 +40,6 @@ private val NotificationParams.extended: ExtendedNotificationParams
|
||||
get() = ExtendedNotificationParams(
|
||||
title,
|
||||
stopText,
|
||||
showStopAction,
|
||||
Core.getSpeedTrafficText(onlyStatisticsProxy),
|
||||
)
|
||||
|
||||
@@ -51,36 +48,31 @@ internal class NotificationModule(
|
||||
private val scope: CoroutineScope,
|
||||
) : ServiceModule {
|
||||
override fun start() {
|
||||
service.ensureNotificationChannel()
|
||||
update(ServiceConfig.notificationParams.value.extended)
|
||||
scope.launch {
|
||||
// Some ROMs, vivo's among them, drop updates posted behind the
|
||||
// keyguard, so unlocking restarts the loop to post once more.
|
||||
// SystemUI, an app, sends USER_PRESENT, which a receiver that is
|
||||
// not exported never gets; all three actions are protected.
|
||||
service.receiveBroadcastFlow(exported = true) {
|
||||
val screenFlow = service.receiveBroadcastFlow {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_USER_PRESENT)
|
||||
}.map { intent ->
|
||||
intent.action != Intent.ACTION_SCREEN_OFF
|
||||
intent.action == Intent.ACTION_SCREEN_ON
|
||||
}.onStart {
|
||||
emit(isScreenOn())
|
||||
}.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,18 +100,17 @@ 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()
|
||||
if (params.showStopAction) {
|
||||
addAction(0, params.stopText, stopIntent)
|
||||
}
|
||||
build()
|
||||
addAction(
|
||||
0,
|
||||
params.stopText,
|
||||
QuickAction.STOP.quickIntent.toPendingIntent,
|
||||
).build()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ internal class ServiceModules(private val service: Service) {
|
||||
val nextModules = listOf(
|
||||
NotificationModule(service, nextScope),
|
||||
NetworkObserveModule(service),
|
||||
SuspendModule(service, nextScope),
|
||||
)
|
||||
val startedModules = mutableListOf<ServiceModule>()
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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.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: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:fillColor="#FFFFFF"/>
|
||||
<path
|
||||
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: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:fillColor="#FFFFFF"/>
|
||||
<path
|
||||
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: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:fillColor="#FFFFFF"/>
|
||||
</vector>
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
android:viewportHeight="240"
|
||||
tools:ignore="VectorRaster">
|
||||
<path
|
||||
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: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:fillColor="#6666FB"/>
|
||||
<path
|
||||
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: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:fillColor="#336AB6"/>
|
||||
<path
|
||||
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: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:fillColor="#5CA8E9"/>
|
||||
</vector>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user