Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8655b0809 | |||
| 6c52d7a39a | |||
| 4d68938d22 | |||
| 213a13f62f | |||
| fefbcf4598 | |||
| 9f48781341 | |||
| dd952dd600 | |||
| bdbf4ab024 | |||
| 347a74b940 | |||
| eb47ee261f | |||
| e82ebf785b | |||
| ba15c313b4 | |||
| 568c49bb3a | |||
| 2201c199b8 | |||
| 5984c161c5 | |||
| a5b6d300f6 | |||
| 03f9553824 | |||
| 8d93268de5 | |||
| 491fae10f5 | |||
| 3abda78af4 | |||
| 8c43053951 | |||
| 532ebf07e9 | |||
| 16770249f1 | |||
| d3fa2252b0 | |||
| 94a25f4827 | |||
| 2fa2f7f816 | |||
| 6c661f662e | |||
| d780cb863d | |||
| 4b59eca853 | |||
| 68c71b8ef9 | |||
| ff23f30c31 | |||
| e55f6d1004 | |||
| 65176f6019 | |||
| 6041cd9289 | |||
| 91e524c354 | |||
| 786a6abca9 | |||
| 1e9955dec6 | |||
| e131675bdf | |||
| e62afb784a | |||
| 8378c7081c | |||
| 841d6035d5 | |||
| ee6a49713a | |||
| 46fb7fee04 | |||
| a390a42032 | |||
| bee8f24269 | |||
| 8068e686a3 | |||
| 1e97716636 | |||
| 2a2522bbc6 | |||
| 12403b063a | |||
| c7be7023d3 | |||
| 7c61c90ac2 | |||
| c5bf5bd487 | |||
| 60f371afd8 | |||
| db03880579 | |||
| d0b1562e1d | |||
| 26cfbaf456 | |||
| aaf934c2d1 | |||
| c0fcbc03ee | |||
| ae29f38f6b | |||
| adf715f8ac | |||
| c6eaa0a68b | |||
| 96fcb9ab5e | |||
| 62addf738a | |||
| e2f678909d | |||
| 7fb4f4f711 | |||
| 903e2b8879 | |||
| 2df430ca31 | |||
| aee7ee2643 | |||
| 9a99897864 | |||
| 4551e57ff4 | |||
| 1cffeaa29e | |||
| 5f2c12642b | |||
| c493df386d | |||
| 425d32b50d | |||
| 51e24ef9de | |||
| 7c831855ef | |||
| 7e7f1f89f3 | |||
| 5179d55dfb | |||
| ac2f6b919e | |||
| 45015f856b | |||
| 672eaccd35 | |||
| 2fbb96f5c1 | |||
| 243b3037d9 | |||
| 6e404ab19c | |||
| 2395a4b20c | |||
| d3c3f04062 | |||
| 201062dc5d | |||
| 45b163184d | |||
| 2ab70f193a | |||
| ed7868282a |
@@ -0,0 +1,32 @@
|
||||
# Agent Configuration Model
|
||||
|
||||
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`: 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.
|
||||
- `.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/hooks.json` or `.codex/config.toml` hooks: lifecycle automation such as prompt checks, command validation, or final verification gates.
|
||||
|
||||
## Placement Rules
|
||||
|
||||
- Put stable team conventions in `AGENTS.md` only when every task must see them without opening another file.
|
||||
- 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
|
||||
|
||||
- Use lowercase hyphenated names.
|
||||
- Start descriptions with `Use when...`.
|
||||
- Describe trigger conditions in the description, not the workflow.
|
||||
- Keep `SKILL.md` lean; link to `.agents/*.md` for large reference material.
|
||||
- Add scripts only when deterministic behavior is needed repeatedly.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
# Commands
|
||||
|
||||
## Building
|
||||
|
||||
Update submodules first. The ClashMeta Go core lives in `core/Clash.Meta/`.
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
Full package build, including Go core, Flutter, and packaging, runs through `setup.dart`:
|
||||
|
||||
```bash
|
||||
dart setup.dart macos
|
||||
dart setup.dart linux
|
||||
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:
|
||||
|
||||
```bash
|
||||
rm -rf .dart_tool/setup_build_cache
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Flutter Development
|
||||
|
||||
Use the default Flutter SDK directly:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run
|
||||
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:
|
||||
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
dart run build_runner watch
|
||||
```
|
||||
|
||||
Code generation covers:
|
||||
|
||||
- Riverpod providers through `riverpod_generator`.
|
||||
- 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.
|
||||
|
||||
```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/
|
||||
flutter test test/widgets/
|
||||
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`.
|
||||
|
||||
## Verify
|
||||
|
||||
In the public repository every branch push runs the `dart` job; in the private
|
||||
`origin` every check job runs only when started by hand (`workflow_dispatch`).
|
||||
The `dart` job performs these root-package checks in order:
|
||||
|
||||
```bash
|
||||
bash tool/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
|
||||
```
|
||||
|
||||
`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` 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
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,104 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Build Dependencies
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
- GCC and Inno Setup.
|
||||
|
||||
macOS:
|
||||
|
||||
```bash
|
||||
npm install -g appdmg
|
||||
```
|
||||
@@ -0,0 +1,663 @@
|
||||
# Rules
|
||||
|
||||
These are repository coding and testing conventions. Codex command permission rules belong in `.codex/rules/*.rules`; see `.agents/agent-config.md` before adding those.
|
||||
|
||||
## 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:
|
||||
|
||||
- `prefer_single_quotes: true`: always use single quotes.
|
||||
- `require_trailing_commas: true`: use trailing commas in multi-line argument lists.
|
||||
- `sort_child_properties_last: true`: `child:` must be the last named parameter.
|
||||
- `avoid_print: true`: do not use `print()` calls.
|
||||
- `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:
|
||||
|
||||
- `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.
|
||||
|
||||
A Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` —
|
||||
leaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever
|
||||
connection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with
|
||||
`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test
|
||||
connection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches
|
||||
it in an unrelated test.
|
||||
|
||||
Register fallback values for freezed params used with `any()` matchers.
|
||||
|
||||
`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:
|
||||
|
||||
```dart
|
||||
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:
|
||||
|
||||
- `lib/l10n/l10n.dart`
|
||||
- `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.
|
||||
@@ -0,0 +1,147 @@
|
||||
# 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`).
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
## Read First
|
||||
|
||||
- `.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.
|
||||
|
||||
## Owners
|
||||
|
||||
Every behavior here has one authoritative owner, and a change lands there:
|
||||
|
||||
- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.
|
||||
- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.
|
||||
- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.
|
||||
- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.
|
||||
- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.
|
||||
- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows and Linux Helper HTTP contract:
|
||||
`lib/core/desktop/helper_client.dart` and `services/helper/`.
|
||||
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; application exit:
|
||||
`SystemExitCoordinator`; UI/event observation: `lib/manager/`.
|
||||
|
||||
An owner has more entry paths than the UI: provider calls, Quick Settings, notification actions, Always-on VPN, revoke
|
||||
callbacks, application exit, and crash/disconnect recovery all reach it. Always-on startup is explicit through
|
||||
`VPN_START_REQUESTED` and revoke through `VPN_REVOKED`; service creation or destruction is never read as intent.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused tests live at the narrowest layer, and the matching commands are in `.agents/commands.md`:
|
||||
|
||||
- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.
|
||||
- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.
|
||||
- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.
|
||||
- Android Kotlin: compile each touched Gradle module with JDK 17.
|
||||
- Helper: `cargo fmt --check` and `cargo test`; the `windows-service` feature only builds on Windows.
|
||||
|
||||
State host gaps explicitly. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, polkit and systemd
|
||||
on Linux, and Windows Service Control Manager behavior need their real platform even when portable tests pass.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- 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`.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: localization
|
||||
description: Use when changing FlClash UI text, scanning for hardcoded Chinese strings, updating ARB localization, or fixing generated locale output in this repository.
|
||||
---
|
||||
|
||||
# Localization
|
||||
|
||||
## 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.
|
||||
|
||||
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/`:
|
||||
|
||||
```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:
|
||||
- `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:
|
||||
|
||||
```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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: pre-commit-quality-review
|
||||
description: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.
|
||||
---
|
||||
|
||||
# Pre-commit Quality Review
|
||||
|
||||
Check that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,
|
||||
so later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill
|
||||
grants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is
|
||||
not consent.
|
||||
|
||||
## Triage
|
||||
|
||||
Most commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a
|
||||
repository-wide scan.
|
||||
|
||||
- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the
|
||||
normal commit flow without extra confirmation. Say the review was skipped, never that it passed.
|
||||
- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions
|
||||
change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws
|
||||
responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.
|
||||
- An explicit structural review request bypasses triage.
|
||||
|
||||
The snapshot under review is the intended commit compared with its baseline:
|
||||
|
||||
| Operation | Baseline |
|
||||
|---|---|
|
||||
| Regular commit | `HEAD` |
|
||||
| Amend | Parent of the commit being replaced |
|
||||
| Squash | Version preceding the agreed range |
|
||||
| Initial commit | Empty tree |
|
||||
|
||||
Ask when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of
|
||||
partially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial
|
||||
staging.
|
||||
|
||||
## Review the Final Structure
|
||||
|
||||
Scope the review to the responsibility boundaries this change touched. Read the complete final implementation of the
|
||||
affected units and compare before and after, not only the last patch. Report issues this change introduced or
|
||||
worsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a
|
||||
concrete concern needs, and disclose missing essential context rather than claiming a complete pass.
|
||||
|
||||
Focus on:
|
||||
|
||||
- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled
|
||||
with implementation detail; responsibilities in layers that should not own them.
|
||||
- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate
|
||||
nothing; similar names or interfaces hiding different responsibilities.
|
||||
- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually
|
||||
dependent helpers; names and decomposition that do not reduce reading effort.
|
||||
- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,
|
||||
scattered ownership of error handling, cleanup, or state updates.
|
||||
- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite
|
||||
invalidated; coupling that makes existing test boundaries hard to use.
|
||||
|
||||
Not findings: length, a missing design pattern, naming taste, or anything without code evidence and a real
|
||||
maintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,
|
||||
or demand abstractions for speculative needs.
|
||||
|
||||
Business logic, UI behavior, performance, security, platform compatibility, and full functional review are out of
|
||||
scope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or
|
||||
widening this review.
|
||||
|
||||
## Findings and the User's Decision
|
||||
|
||||
A finding qualifies only when all three hold:
|
||||
|
||||
- Before-and-after code and the necessary call relationships show this change introduced or worsened it.
|
||||
- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one
|
||||
responsibility. Clarity or elegance alone is not enough.
|
||||
- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.
|
||||
|
||||
With no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the
|
||||
existing commit authorization. Do not ask for confirmation because a review happened, and do not collect optional
|
||||
improvements into a suggestion list.
|
||||
|
||||
With qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,
|
||||
and give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the
|
||||
smallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to
|
||||
optimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the
|
||||
user recognizes their configured workflow, for example: "The structural issues above support consolidating the
|
||||
duplicate state-update paths. Should I make that change before committing?"
|
||||
|
||||
Existing explicit authorization for these findings is enough to act without asking again. The user may accept all,
|
||||
some, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were
|
||||
restaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.
|
||||
|
||||
## Resolve and Finish
|
||||
|
||||
Once authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task
|
||||
scope; explain separately if either must change. Verify only the accepted findings and the direct effects of the
|
||||
adjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold
|
||||
and exceeds the existing authorization.
|
||||
|
||||
Before committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and
|
||||
review only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,
|
||||
`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse
|
||||
results that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot
|
||||
as verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the
|
||||
commit.
|
||||
|
||||
Report the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.
|
||||
Claim the commit is done only after it exists.
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
## Conventions
|
||||
|
||||
- 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`.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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 manually edit generated localization or provider files.
|
||||
@@ -0,0 +1,187 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Worktrees
|
||||
|
||||
Claude Code, Codex, and Gemini each create git worktrees. `tool/worktrees.sh list` shows every worktree with its
|
||||
owning tool and whether it holds uncommitted work; `tool/worktrees.sh prune` removes the clean ones and keeps
|
||||
dirty ones unless `--force` is passed. `.claude/worktrees/` is ignored by git; `.claude/settings.json` is tracked.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/core-platform
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/localization
|
||||
@@ -0,0 +1 @@
|
||||
../../.agents/skills/pre-commit-quality-review
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/ui-work
|
||||
@@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,8 @@
|
||||
[[hooks.PostToolUse]]
|
||||
matcher = "apply_patch|Bash"
|
||||
|
||||
[[hooks.PostToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "bash tool/hooks/comment_density.sh"
|
||||
timeout = 15
|
||||
statusMessage = "Checking comment density"
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# freezed emits lines of trailing spaces, and generated code is never edited by hand.
|
||||
lib/**/generated/** -whitespace
|
||||
@@ -0,0 +1,41 @@
|
||||
cask "flclash" do
|
||||
version "VERSION"
|
||||
|
||||
on_macos do
|
||||
arch arm: "arm64", intel: "amd64"
|
||||
|
||||
sha256 arm: "ARM_SHA256",
|
||||
intel: "AMD_SHA256"
|
||||
|
||||
url "https://github.com/chen08209/FlClash/releases/download/v#{version}/FlClash-#{version}-macos-#{arch}.dmg"
|
||||
end
|
||||
|
||||
name "FlClash"
|
||||
desc "Multi-platform proxy client based on ClashMeta"
|
||||
homepage "https://github.com/chen08209/FlClash"
|
||||
|
||||
livecheck do
|
||||
url :url
|
||||
strategy :github_latest
|
||||
end
|
||||
|
||||
depends_on :macos
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
uninstall quit: "com.follow.clash"
|
||||
|
||||
zap trash: [
|
||||
"~/Library/Application Support/com.follow.clash",
|
||||
"~/Library/Caches/com.follow.clash",
|
||||
"~/Library/Preferences/com.follow.clash.plist",
|
||||
"~/Library/Saved Application State/com.follow.clash.savedState",
|
||||
]
|
||||
end
|
||||
@@ -42,7 +42,7 @@
|
||||
<td>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.AppImage"><img src="https://img.shields.io/badge/AppImage-x64-f84e29.svg?logo=linux"> </a><br>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.deb"><img src="https://img.shields.io/badge/DebPackage-x64-FF9966.svg?logo=debian"> </a><br>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.deb"><img src="https://img.shields.io/badge/RpmPackage-x64-F1B42F.svg?logo=redhat"> </a>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.rpm"><img src="https://img.shields.io/badge/RpmPackage-x64-F1B42F.svg?logo=redhat"> </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
+443
-117
@@ -2,13 +2,329 @@ name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
env:
|
||||
IS_STABLE: ${{ !contains(github.ref, '-') }}
|
||||
# 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
|
||||
|
||||
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
|
||||
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: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libsecret-1-dev
|
||||
|
||||
- name: Setup Xcode
|
||||
if: matrix.platform == 'macos'
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Enable git long paths (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
run: git config --global core.longpaths true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
# Compiles the desktop runners and the native plugin code that only the
|
||||
# tagged release matrix built before; the bundle itself is discarded.
|
||||
- name: Build debug bundle
|
||||
run: flutter build ${{ matrix.platform }} --debug
|
||||
|
||||
go:
|
||||
name: Go core
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Validate Go core wrapper
|
||||
working-directory: core
|
||||
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
|
||||
|
||||
- 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
|
||||
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 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
|
||||
|
||||
- name: Run Windows service tests
|
||||
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')
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -16,13 +332,13 @@ jobs:
|
||||
- platform: android
|
||||
os: ubuntu-latest
|
||||
- platform: windows
|
||||
os: windows-latest
|
||||
os: Windows-2022
|
||||
arch: amd64
|
||||
- platform: linux
|
||||
os: ubuntu-22.04
|
||||
arch: amd64
|
||||
- platform: macos
|
||||
os: macos-13
|
||||
os: macos-15-intel
|
||||
arch: amd64
|
||||
- platform: macos
|
||||
os: macos-latest
|
||||
@@ -30,13 +346,16 @@ 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
|
||||
if: startsWith(matrix.os, 'windows-11-arm')
|
||||
shell: pwsh
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://win.rustup.rs/aarch64" -OutFile rustup-init.exe
|
||||
.\rustup-init.exe -y --default-toolchain stable
|
||||
@@ -52,36 +371,77 @@ jobs:
|
||||
if: startsWith(matrix.platform,'android')
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
|
||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/local.properties
|
||||
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/local.properties
|
||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/local.properties
|
||||
{
|
||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}"
|
||||
echo "storePassword=${{ secrets.STORE_PASSWORD }}"
|
||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}"
|
||||
} >> android/local.properties
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.0'
|
||||
cache-dependency-path: |
|
||||
core/go.sum
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Setup Flutter Master
|
||||
if: startsWith(matrix.os, 'windows-11-arm') || startsWith(matrix.os, 'ubuntu-24.04-arm')
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'master'
|
||||
cache: true
|
||||
- name: Setup Gradle
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Enable git long paths (Windows)
|
||||
if: startsWith(matrix.os, 'windows')
|
||||
run: git config --global core.longpaths true
|
||||
|
||||
# Stable publishes no Windows or Linux arm64 archive; master makes the action
|
||||
# clone the SDK at the pinned tag and let it fetch the host's own Dart SDK.
|
||||
- name: Setup Flutter
|
||||
if: ${{ !(startsWith(matrix.os, 'windows-11-arm') || startsWith(matrix.os, 'ubuntu-24.04-arm')) }}
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
# flutter-version: 3.29.3
|
||||
channel: ${{ matrix.flutter-channel || 'stable' }}
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Setup Xcode
|
||||
if: startsWith(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: Setup NDK
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: build-ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
add-to-path: true
|
||||
link-to-sdk: true
|
||||
|
||||
- name: Get Flutter Dependency
|
||||
run: flutter pub get
|
||||
run: |
|
||||
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
|
||||
run: dart setup.dart ${{ matrix.platform }} ${{ matrix.arch && format('--arch {0}', matrix.arch) }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }}
|
||||
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
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -90,72 +450,10 @@ jobs:
|
||||
path: ./dist
|
||||
overwrite: true
|
||||
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ build ]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: refs/heads/main
|
||||
- name: Generate
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
tags=($(git tag --merged $(git rev-parse HEAD) --sort=-creatordate))
|
||||
preTag=$(grep -oP '^## \K.*' CHANGELOG.md | head -n 1)
|
||||
currentTag=""
|
||||
for ((i = 0; i <= ${#tags[@]}; i++)); do
|
||||
if (( i < ${#tags[@]} )); then
|
||||
tag=${tags[$i]}
|
||||
else
|
||||
tag=""
|
||||
fi
|
||||
if [ -n "$currentTag" ]; then
|
||||
if [ "$(echo -e "$currentTag\n$preTag" | sort -V | head -n 1)" == "$currentTag" ]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
if [ -n "$currentTag" ]; then
|
||||
echo "## $currentTag" >> NEW_CHANGELOG.md
|
||||
echo "" >> NEW_CHANGELOG.md
|
||||
if [ -n "$tag" ]; then
|
||||
git log --pretty=format:"%B" "$tag..$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> NEW_CHANGELOG.md
|
||||
else
|
||||
git log --pretty=format:"%B" "$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> NEW_CHANGELOG.md
|
||||
fi
|
||||
echo "" >> NEW_CHANGELOG.md
|
||||
fi
|
||||
currentTag=$tag
|
||||
done
|
||||
cat CHANGELOG.md >> NEW_CHANGELOG.md
|
||||
cat NEW_CHANGELOG.md > CHANGELOG.md
|
||||
|
||||
- name: Commit
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
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"
|
||||
git push
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Push succeeded"
|
||||
else
|
||||
echo "Push failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
upload:
|
||||
permissions: write-all
|
||||
needs: [ build ]
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [ build, dart, plugins, go, android, rust, windows-helper-test ]
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
telegram-bot-api:
|
||||
@@ -178,32 +476,34 @@ jobs:
|
||||
pattern: artifact-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate release.md
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: ${{ env.BRANCH_CACHE }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Skip native build hooks
|
||||
run: |
|
||||
tags=($(git tag --merged $(git rev-parse HEAD) --sort=-creatordate))
|
||||
preTag=$(curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" | grep -Po '"tag_name": "\K.*?(?=")' || echo "")
|
||||
currentTag=""
|
||||
for ((i = 0; i <= ${#tags[@]}; i++)); do
|
||||
if (( i < ${#tags[@]} )); then
|
||||
tag=${tags[$i]}
|
||||
else
|
||||
tag=""
|
||||
fi
|
||||
if [ -n "$currentTag" ]; then
|
||||
if [ "$(echo -e "$currentTag\n$preTag" | sort -V | head -n 1)" == "$currentTag" ]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
if [ -n "$currentTag" ]; then
|
||||
if [ -n "$tag" ]; then
|
||||
git log --pretty=format:"%B" "$tag..$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> release.md
|
||||
else
|
||||
git log --pretty=format:"%B" "$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> release.md
|
||||
fi
|
||||
echo "" >> release.md
|
||||
fi
|
||||
currentTag=$tag
|
||||
done
|
||||
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false' pubspec.yaml
|
||||
|
||||
- name: Generate release notes
|
||||
shell: bash
|
||||
env:
|
||||
IS_STABLE: ${{ env.IS_STABLE }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
target=()
|
||||
if [[ "$IS_STABLE" == 'true' ]]; then
|
||||
target=(--tag "$TAG")
|
||||
else
|
||||
dart run tool/changelog.dart build --unreleased
|
||||
fi
|
||||
dart run tool/changelog.dart render release "${target[@]}" --out release.md
|
||||
dart run tool/changelog.dart render telegram "${target[@]}" --out telegram.md
|
||||
|
||||
- name: Push to telegram
|
||||
env:
|
||||
@@ -221,12 +521,10 @@ jobs:
|
||||
sed "s|VERSION|$version|g" ./.github/release_template.md >> release.md
|
||||
|
||||
- name: Generate sha256
|
||||
if: env.IS_STABLE == 'true'
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
cd ./dist
|
||||
for file in $(find . -type f -not -name "*.sha256"); do
|
||||
sha256sum "$file" > "${file}.sha256"
|
||||
done
|
||||
sha256sum ./* > SHA256SUMS
|
||||
|
||||
- name: Release
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
@@ -235,6 +533,35 @@ jobs:
|
||||
files: ./dist/*
|
||||
body_path: './release.md'
|
||||
|
||||
- name: Create Homebrew Cask Source Dir
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
version=$(echo "${{ github.ref_name }}" | sed 's/^v//')
|
||||
arm_sha=$(sha256sum "./dist/FlClash-${version}-macos-arm64.dmg" | awk '{print $1}')
|
||||
amd_sha=$(sha256sum "./dist/FlClash-${version}-macos-amd64.dmg" | awk '{print $1}')
|
||||
|
||||
mkdir -p ./brew/Casks
|
||||
sed \
|
||||
-e "s|VERSION|$version|g" \
|
||||
-e "s|ARM_SHA256|$arm_sha|g" \
|
||||
-e "s|AMD_SHA256|$amd_sha|g" \
|
||||
./.github/homebrew_cask_template.rb > ./brew/Casks/flclash.rb
|
||||
|
||||
- name: Push to Homebrew tap
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
uses: cpina/github-action-push-to-another-repository@v1.7.2
|
||||
env:
|
||||
SSH_DEPLOY_KEY: ${{ secrets.HOMEBREW_SSH_DEPLOY_KEY }}
|
||||
with:
|
||||
source-directory: ./brew/
|
||||
destination-github-username: chen08209
|
||||
destination-repository-name: homebrew-tap
|
||||
user-name: 'chen08029'
|
||||
user-email: 'chen08209@gmail.com'
|
||||
target-branch: main
|
||||
commit-message: Update Homebrew cask from ${{ github.ref_name }}
|
||||
target-directory: /
|
||||
|
||||
- name: Create Fdroid Source Dir
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
@@ -251,9 +578,8 @@ jobs:
|
||||
source-directory: ./tmp/
|
||||
destination-github-username: chen08209
|
||||
destination-repository-name: FlClash-fdroid-repo
|
||||
user-name: 'github-actions[bot]'
|
||||
user-email: 'github-actions[bot]@users.noreply.github.com'
|
||||
user-name: 'chen08029'
|
||||
user-email: 'chen08209@gmail.com'
|
||||
target-branch: main
|
||||
commit-message: Update from ${{ github.ref_name }}
|
||||
target-directory: /tmp/
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
name: pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'site/**'
|
||||
- 'tool/build_site.sh'
|
||||
- 'tool/render_site.mjs'
|
||||
- '.github/workflows/pages.yaml'
|
||||
workflow_run:
|
||||
workflows:
|
||||
- build
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
# head_branch also names a branch push's branch, so only a tag at the built commit counts as a release.
|
||||
if: >-
|
||||
github.repository == 'chen08209/FlClash' &&
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v') &&
|
||||
!contains(github.event.workflow_run.head_branch, '-')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
stable: ${{ steps.lookup.outputs.stable }}
|
||||
steps:
|
||||
- name: Look up the tag
|
||||
id: lookup
|
||||
env:
|
||||
NAME: ${{ github.event.workflow_run.head_branch }}
|
||||
SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
REMOTE: ${{ github.server_url }}/${{ github.repository }}
|
||||
run: |
|
||||
if git ls-remote --tags "$REMOTE" "refs/tags/$NAME" "refs/tags/$NAME^{}" | grep -q "^$SHA"; then
|
||||
echo 'stable=true' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
deploy:
|
||||
needs: tag
|
||||
if: >-
|
||||
github.repository == 'chen08209/FlClash' &&
|
||||
!cancelled() &&
|
||||
(github.event_name != 'workflow_run' || needs.tag.outputs.stable == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Assemble site
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bash tool/build_site.sh _site
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: _site
|
||||
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
+31
-6
@@ -21,7 +21,7 @@ migrate_working_dir/
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
@@ -41,15 +41,40 @@ app.*.symbols
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Claude Code - ignore user-specific settings only
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Documentation (generated/planning artifacts)
|
||||
docs/
|
||||
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
/android/**/.cxx
|
||||
/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/
|
||||
|
||||
|
||||
|
||||
#libclash
|
||||
#FlClash
|
||||
/libclash/
|
||||
|
||||
#jniLibs
|
||||
/android/app/src/main/jniLibs/
|
||||
/services/helper/target
|
||||
/macos/**/Package.resolved
|
||||
/macos/build/
|
||||
/env.json
|
||||
/core_sha256.json
|
||||
/plugins/rust_api/.claude/
|
||||
/issue-triage/
|
||||
devtools_options.yaml
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
.fvmrc
|
||||
/coverage/lcov.info
|
||||
|
||||
@@ -2,9 +2,3 @@
|
||||
path = core/Clash.Meta
|
||||
url = git@github.com:chen08209/Clash.Meta.git
|
||||
branch = FlClash
|
||||
[submodule "plugins/flutter_distributor"]
|
||||
path = plugins/flutter_distributor
|
||||
url = git@github.com:chen08209/flutter_distributor.git
|
||||
branch = FlClash
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled.
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
channel: stable
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
@@ -13,26 +13,11 @@ project_type: app
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: android
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: ios
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: linux
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: macos
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: web
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
- platform: windows
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
|
||||
# User provided section
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: dart-format
|
||||
name: dart format
|
||||
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)
|
||||
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
|
||||
language: system
|
||||
stages: [commit-msg]
|
||||
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="main.dart" type="FlutterRunConfigurationType" factoryName="Flutter">
|
||||
<option name="additionalArgs" value="--dart-define-from-file env.json" />
|
||||
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="main.dart (safe mode)" type="FlutterRunConfigurationType" factoryName="Flutter">
|
||||
<option name="additionalArgs" value="--dart-define-from-file env.json --dart-define=SAFE_MODE=true" />
|
||||
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{
|
||||
"label": "Flutter App (With Env)",
|
||||
"adapter": "Dart",
|
||||
"type": "flutter",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"cwd": "$ZED_WORKTREE_ROOT",
|
||||
"toolArgs": ["--dart-define-from-file", "env.json"],
|
||||
},
|
||||
{
|
||||
"label": "Flutter App (Safe Mode)",
|
||||
"adapter": "Dart",
|
||||
"type": "flutter",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"cwd": "$ZED_WORKTREE_ROOT",
|
||||
"toolArgs": [
|
||||
"--dart-define-from-file",
|
||||
"env.json",
|
||||
"--dart-define=SAFE_MODE=true",
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"lsp": {
|
||||
"gopls": {
|
||||
"binary": {
|
||||
"env": {
|
||||
"CGO_ENABLED": "0",
|
||||
// "GOOS": "android",
|
||||
// "GOARCH": "arm64",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[
|
||||
{
|
||||
"label": "flutter run (With Env)",
|
||||
"command": "flutter",
|
||||
"args": ["run", "--dart-define-from-file", "env.json"],
|
||||
"cwd": "$ZED_WORKTREE_ROOT",
|
||||
"use_new_terminal": true,
|
||||
},
|
||||
{
|
||||
"label": "flutter run (Safe Mode)",
|
||||
"command": "flutter",
|
||||
"args": [
|
||||
"run",
|
||||
"--dart-define-from-file",
|
||||
"env.json",
|
||||
"--dart-define=SAFE_MODE=true",
|
||||
],
|
||||
"cwd": "$ZED_WORKTREE_ROOT",
|
||||
"use_new_terminal": true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file is the entry point for AI coding agents working in this repository. Keep it small: detailed guidance lives under
|
||||
`.agents/`, and discoverable repo skills live under `.agents/skills/*/SKILL.md`.
|
||||
|
||||
## Start Here
|
||||
|
||||
Read these files before making changes:
|
||||
|
||||
- [.agents/project.md](.agents/project.md): project overview, versions, and build dependencies.
|
||||
- [.agents/commands.md](.agents/commands.md): build, development, code generation, and test commands.
|
||||
- [.agents/rules.md](.agents/rules.md): lint, testing, generated-code, and workflow rules.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## 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.
|
||||
+231
-370
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
android_arm64:
|
||||
dart ./setup.dart android --arch arm64
|
||||
macos_arm64:
|
||||
dart ./setup.dart macos --arch arm64
|
||||
android_app:
|
||||
dart ./setup.dart android
|
||||
android_arm64_core:
|
||||
dart ./setup.dart android --arch arm64 --out core
|
||||
macos_arm64_core:
|
||||
dart ./setup.dart macos --arch arm64 --out core
|
||||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: help submodules
|
||||
|
||||
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.'
|
||||
|
||||
submodules:
|
||||
git submodule update --init --recursive
|
||||
|
||||
@@ -1,127 +1,138 @@
|
||||
<div>
|
||||
<div align="center">
|
||||
|
||||
[**简体中文**](README_zh_CN.md)
|
||||
<img src="assets/images/icon.png" alt="FlClash" width="88">
|
||||
|
||||
# FlClash
|
||||
|
||||
A multi-platform proxy client based on ClashMeta. Simple to use, open source and ad-free.
|
||||
|
||||
**English** · [简体中文](README_zh_CN.md)
|
||||
|
||||
[](https://github.com/chen08209/FlClash/releases/latest)
|
||||
[](https://github.com/chen08209/FlClash/releases)
|
||||
[](LICENSE)
|
||||
[](https://t.me/FlClash)
|
||||
|
||||
[Website](https://chen08209.github.io/FlClash) · [Download](#download) · [Changelog](CHANGELOG.md) · [Build from source](#build-from-source)
|
||||
|
||||
</div>
|
||||
|
||||
## FlClash
|
||||
|
||||
[](https://github.com/chen08209/FlClash/releases/)[](https://github.com/chen08209/FlClash/releases/)[](LICENSE)
|
||||
|
||||
[](https://t.me/FlClash)
|
||||
|
||||
A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
|
||||
|
||||
on Desktop:
|
||||
<p style="text-align: center;">
|
||||
<img alt="desktop" src="snapshots/desktop.gif">
|
||||
</p>
|
||||
|
||||
on Mobile:
|
||||
<p style="text-align: center;">
|
||||
<img alt="mobile" src="snapshots/mobile.gif">
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="snapshots/preview-dark.png">
|
||||
<img alt="FlClash dashboard on a MacBook and a phone" src="snapshots/preview.png" width="92%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
✈️ Multi-platform: Android, Windows, macOS and Linux
|
||||
|
||||
💻 Adaptive multiple screen sizes, Multiple color themes available
|
||||
|
||||
💡 Based on Material You Design, [Surfboard](https://github.com/getsurfboard/surfboard)-like UI
|
||||
|
||||
☁️ Supports data sync via WebDAV
|
||||
|
||||
✨ Support subscription link, Dark mode
|
||||
|
||||
## Use
|
||||
|
||||
### Linux
|
||||
|
||||
⚠️ Make sure to install the following dependencies before using them
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
sudo apt-get install libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
Support the following actions
|
||||
|
||||
```bash
|
||||
com.follow.clash.action.START
|
||||
|
||||
com.follow.clash.action.STOP
|
||||
|
||||
com.follow.clash.action.TOGGLE
|
||||
```
|
||||
- **One app for Android, Windows, macOS and Linux**, with x64 and ARM64 builds on the desktop.
|
||||
- **mihomo (Clash.Meta) core** with rule routing, proxy groups, latency tests, system proxy and TUN mode.
|
||||
- **Profiles** from a subscription link or a file, with a built-in editor, override scripts, and custom rules,
|
||||
proxies and proxy groups.
|
||||
- **Live views** of connections, requests, DNS queries and logs.
|
||||
- **Material You design with 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.
|
||||
|
||||
## Download
|
||||
|
||||
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
|
||||
Get the latest build from [GitHub Releases](https://github.com/chen08209/FlClash/releases/latest), or open the
|
||||
[website](https://chen08209.github.io/FlClash#download), which picks the right file for your device.
|
||||
|
||||
## Build
|
||||
| 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. |
|
||||
|
||||
1. Update submodules
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
<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>
|
||||
|
||||
2. Install `Flutter` and `Golang` environment
|
||||
**Homebrew**
|
||||
|
||||
3. Build Application
|
||||
```bash
|
||||
brew tap chen08209/tap
|
||||
brew install --cask flclash
|
||||
```
|
||||
|
||||
- android
|
||||
**Linux tray icon**
|
||||
|
||||
1. Install `Android SDK` , `Android NDK`
|
||||
The `.deb` package installs its own dependencies. With the AppImage or the `.rpm`, install the AyatanaAppIndicator
|
||||
library so the tray icon can show:
|
||||
|
||||
2. Set `ANDROID_NDK` environment variables
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-1 # Debian and Ubuntu
|
||||
sudo dnf install libayatana-appindicator-gtk3 # Fedora
|
||||
```
|
||||
|
||||
3. Run Build script
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
dart .\setup.dart android
|
||||
```
|
||||
**Import a profile from a link.** Opening a link in this form imports the subscription into FlClash. The `clashmeta://`
|
||||
and `flclash://` schemes work the same way.
|
||||
|
||||
- windows
|
||||
```text
|
||||
clash://install-config?url=<URL-encoded subscription link>
|
||||
```
|
||||
|
||||
1. You need a windows client
|
||||
**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. Install `Gcc`,`Inno Setup`
|
||||
```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 windows --arch <arm64 | amd64>
|
||||
```
|
||||
```bash
|
||||
adb shell am start -a com.follow.clash.action.TOGGLE
|
||||
```
|
||||
|
||||
- linux
|
||||
## Build from source
|
||||
|
||||
1. You need a linux 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. Run build script
|
||||
```bash
|
||||
git clone --recursive https://github.com/chen08209/FlClash.git
|
||||
cd FlClash
|
||||
flutter pub get
|
||||
dart setup.dart android # or windows, macos, linux
|
||||
```
|
||||
|
||||
```bash
|
||||
dart .\setup.dart linux --arch <arm64 | amd64>
|
||||
```
|
||||
Packages land in `dist/`. The Go core and the Rust libraries are compiled as part of the Flutter build.
|
||||
|
||||
- macOS
|
||||
| Platform | Also needed |
|
||||
| --- | --- |
|
||||
| Android | Android SDK with the NDK. Add `--arch arm64` to build a single ABI. |
|
||||
| Windows | GCC (MinGW-w64) for the core and [Inno Setup](https://jrsoftware.org/isinfo.php) 6 for the installer. |
|
||||
| macOS | Xcode and Node.js. The script installs `appdmg` through npm. |
|
||||
| Linux | Debian or Ubuntu. The script installs the build packages with apt and downloads `appimagetool`. |
|
||||
|
||||
1. You need a macOS client
|
||||
Run `dart setup.dart --help` for the remaining options, such as `--targets` to build only some package formats.
|
||||
|
||||
2. Run build script
|
||||
## Support
|
||||
|
||||
```bash
|
||||
dart .\setup.dart macos --arch <arm64 | amd64>
|
||||
```
|
||||
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).
|
||||
|
||||
## Star
|
||||
<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>
|
||||
|
||||
The easiest way to support developers is to click on the star (⭐) at the top of the page.
|
||||
## License
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
|
||||
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
|
||||
</a>
|
||||
</p>
|
||||
FlClash is released under the [GPL-3.0 license](LICENSE).
|
||||
|
||||
+100
-97
@@ -1,127 +1,130 @@
|
||||
<div>
|
||||
<div align="center">
|
||||
|
||||
[**English**](README.md)
|
||||
<img src="assets/images/icon.png" alt="FlClash" width="88">
|
||||
|
||||
# FlClash
|
||||
|
||||
基于 ClashMeta 的多平台代理客户端,简单易用,开源无广告。
|
||||
|
||||
[English](README.md) · **简体中文**
|
||||
|
||||
[](https://github.com/chen08209/FlClash/releases/latest)
|
||||
[](https://github.com/chen08209/FlClash/releases)
|
||||
[](LICENSE)
|
||||
[](https://t.me/FlClash)
|
||||
|
||||
[官网](https://chen08209.github.io/FlClash/zh) · [下载](#下载) · [更新日志](CHANGELOG.md) · [从源码构建](#从源码构建)
|
||||
|
||||
</div>
|
||||
|
||||
## FlClash
|
||||
|
||||
[](https://github.com/chen08209/FlClash/releases/)[](https://github.com/chen08209/FlClash/releases/)[](LICENSE)
|
||||
|
||||
[](https://t.me/FlClash)
|
||||
|
||||
基于ClashMeta的多平台代理客户端,简单易用,开源无广告。
|
||||
|
||||
on Desktop:
|
||||
<p style="text-align: center;">
|
||||
<img alt="desktop" src="snapshots/desktop.gif">
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="snapshots/preview-dark.png">
|
||||
<img alt="FlClash 在 MacBook 与手机上的仪表盘" src="snapshots/preview.png" width="92%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
on Mobile:
|
||||
<p style="text-align: center;">
|
||||
<img alt="mobile" src="snapshots/mobile.gif">
|
||||
## 功能
|
||||
|
||||
- **一个应用覆盖 Android、Windows、macOS 和 Linux**,桌面端同时提供 x64 与 ARM64 版本。
|
||||
- **mihomo(Clash.Meta)内核**:规则分流、代理组、延迟测试、系统代理与 TUN 模式。
|
||||
- **配置管理**:通过订阅链接或文件导入配置,内置编辑器,支持覆写脚本以及自定义规则、代理和代理组。
|
||||
- **实时查看**连接、请求、DNS 查询和日志。
|
||||
- **Material You 设计,融合 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>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
**Homebrew**
|
||||
|
||||
✈️ 多平台: Android, Windows, macOS and Linux
|
||||
```bash
|
||||
brew tap chen08209/tap
|
||||
brew install --cask flclash
|
||||
```
|
||||
|
||||
💻 自适应多个屏幕尺寸,多种颜色主题可供选择
|
||||
**Linux 托盘图标**
|
||||
|
||||
💡 基本 Material You 设计, 类[Surfboard](https://github.com/getsurfboard/surfboard)用户界面
|
||||
`.deb` 安装包会自动安装所需依赖。使用 AppImage 或 `.rpm` 时,需要先安装 AyatanaAppIndicator 库,托盘图标才能显示:
|
||||
|
||||
☁️ 支持通过WebDAV同步数据
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-1 # Debian 与 Ubuntu
|
||||
sudo dnf install libayatana-appindicator-gtk3 # Fedora
|
||||
```
|
||||
|
||||
✨ 支持一键导入订阅, 深色模式
|
||||
## 使用
|
||||
|
||||
## Use
|
||||
**通过链接导入配置。** 打开下面格式的链接,即可把订阅导入 FlClash。`clashmeta://` 和 `flclash://` 开头的链接同样可用。
|
||||
|
||||
### Linux
|
||||
```text
|
||||
clash://install-config?url=<经过 URL 编码的订阅链接>
|
||||
```
|
||||
|
||||
⚠️ 使用前请确保安装以下依赖
|
||||
**Android 自动化。** Tasker、MacroDroid 等应用可以用下面的 Action 启动 Activity,从而启动、停止或切换代理:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
sudo apt-get install libkeybinder-3.0-dev
|
||||
```
|
||||
```text
|
||||
com.follow.clash.action.START
|
||||
com.follow.clash.action.STOP
|
||||
com.follow.clash.action.TOGGLE
|
||||
```
|
||||
|
||||
### Android
|
||||
在电脑上也可以通过 adb 触发:
|
||||
|
||||
支持下列操作
|
||||
```bash
|
||||
adb shell am start -a com.follow.clash.action.TOGGLE
|
||||
```
|
||||
|
||||
```bash
|
||||
com.follow.clash.action.START
|
||||
|
||||
com.follow.clash.action.STOP
|
||||
|
||||
com.follow.clash.action.TOGGLE
|
||||
```
|
||||
## 从源码构建
|
||||
|
||||
## Download
|
||||
需要 [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 在任意系统上都能构建。
|
||||
|
||||
<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>
|
||||
```bash
|
||||
git clone --recursive https://github.com/chen08209/FlClash.git
|
||||
cd FlClash
|
||||
flutter pub get
|
||||
dart setup.dart android # 或 windows、macos、linux
|
||||
```
|
||||
|
||||
## Build
|
||||
安装包输出到 `dist/`。Go 内核和 Rust 库会在 Flutter 构建过程中一并编译。
|
||||
|
||||
1. 更新 submodules
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
| 平台 | 额外依赖 |
|
||||
| --- | --- |
|
||||
| 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`。 |
|
||||
|
||||
2. 安装 `Flutter` 以及 `Golang` 环境
|
||||
其余选项可以运行 `dart setup.dart --help` 查看,例如用 `--targets` 只构建部分安装包格式。
|
||||
|
||||
3. 构建应用
|
||||
## 支持
|
||||
|
||||
- android
|
||||
给仓库点一个 Star 是支持项目最简单的方式。问题讨论和更新通知请关注 [Telegram 频道](https://t.me/FlClash),
|
||||
Bug 与功能建议请提交到 [GitHub Issues](https://github.com/chen08209/FlClash/issues)。
|
||||
|
||||
1. 安装 `Android SDK` , `Android NDK`
|
||||
<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>
|
||||
|
||||
2. 设置 `ANDROID_NDK` 环境变量
|
||||
## 许可证
|
||||
|
||||
3. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart android
|
||||
```
|
||||
|
||||
- windows
|
||||
|
||||
1. 你需要一个windows客户端
|
||||
|
||||
2. 安装 `Gcc`,`Inno Setup`
|
||||
|
||||
3. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart windows --arch <arm64 | amd64>
|
||||
```
|
||||
|
||||
- linux
|
||||
|
||||
1. 你需要一个linux客户端
|
||||
|
||||
2. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart linux --arch <arm64 | amd64>
|
||||
```
|
||||
|
||||
- macOS
|
||||
|
||||
1. 你需要一个macOS客户端
|
||||
|
||||
2. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart macos --arch <arm64 | amd64>
|
||||
```
|
||||
|
||||
## Star History
|
||||
|
||||
支持开发者的最简单方式是点击页面顶部的星标(⭐)。
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
|
||||
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
|
||||
</a>
|
||||
</p>
|
||||
FlClash 以 [GPL-3.0 协议](LICENSE)发布。
|
||||
|
||||
+11
-5
@@ -1,10 +1,16 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
include: lint_options.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- lib/l10n/intl/**
|
||||
- lib/**/generated/**
|
||||
- plugins/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
errors:
|
||||
invalid_annotation_target: ignore
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_single_quotes: true
|
||||
@@ -3,33 +3,30 @@ import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val localPropertiesFile = rootProject.file("local.properties")
|
||||
val localProperties = Properties().apply {
|
||||
val localPropertiesFile = rootProject.file("local.properties")
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val mStoreFile: File = file("keystore.jks")
|
||||
val mStorePassword: String? = localProperties.getProperty("storePassword")
|
||||
val mKeyAlias: String? = localProperties.getProperty("keyAlias")
|
||||
val mKeyPassword: String? = localProperties.getProperty("keyPassword")
|
||||
val isRelease = mStoreFile.exists()
|
||||
&& mStorePassword != null
|
||||
&& mKeyAlias != null
|
||||
&& mKeyPassword != null
|
||||
val releaseStoreFile = file("keystore.jks")
|
||||
val releaseStorePassword = localProperties.getProperty("storePassword")
|
||||
val releaseKeyAlias = localProperties.getProperty("keyAlias")
|
||||
val releaseKeyPassword = localProperties.getProperty("keyPassword")
|
||||
val hasReleaseSigning = releaseStoreFile.exists() &&
|
||||
releaseStorePassword != null &&
|
||||
releaseKeyAlias != null &&
|
||||
releaseKeyPassword != null
|
||||
|
||||
android {
|
||||
namespace = "com.follow.clash"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
ndkVersion = libs.versions.ndkVersion.get()
|
||||
|
||||
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
@@ -44,12 +41,12 @@ android {
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (isRelease) {
|
||||
if (hasReleaseSigning) {
|
||||
create("release") {
|
||||
storeFile = mStoreFile
|
||||
storePassword = mStorePassword
|
||||
keyAlias = mKeyAlias
|
||||
keyPassword = mKeyPassword
|
||||
storeFile = releaseStoreFile
|
||||
storePassword = releaseStorePassword
|
||||
keyAlias = releaseKeyAlias
|
||||
keyPassword = releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,24 +60,30 @@ android {
|
||||
buildTypes {
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
applicationIdSuffix = ".debug"
|
||||
applicationIdSuffix = ".dev"
|
||||
}
|
||||
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
signingConfig = if (isRelease) {
|
||||
signingConfigs.getByName("release")
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
applicationIdSuffix = ".dev"
|
||||
}
|
||||
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
// Unit tests live under android/tests/ instead of each module's src/test.
|
||||
getByName("test").java.setSrcDirs(listOf("../tests/app"))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -96,9 +99,12 @@ flutter {
|
||||
dependencies {
|
||||
implementation(project(":service"))
|
||||
implementation(project(":common"))
|
||||
implementation(project(":core"))
|
||||
implementation(libs.core.splashscreen)
|
||||
implementation(libs.gson)
|
||||
implementation(libs.smali.dexlib2) {
|
||||
exclude(group = "com.google.guava", module = "guava")
|
||||
}
|
||||
}
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -1,4 +1,4 @@
|
||||
|
||||
-keep class com.follow.clash.models.**{ *; }
|
||||
-keep class com.follow.clash.models.** { *; }
|
||||
|
||||
-keep class com.follow.clash.service.models.**{ *; }
|
||||
-keep class com.follow.clash.service.models.** { *; }
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="FlClash Debug"
|
||||
@@ -11,7 +7,6 @@
|
||||
<service
|
||||
android:name=".TileService"
|
||||
android:label="FlClash Debug"
|
||||
tools:replace="android:label"
|
||||
tools:targetApi="24" />
|
||||
tools:replace="android:label" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -11,24 +11,30 @@
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.wifi"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<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=".Application"
|
||||
android:name=".FlClashApplication"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="FlClash">
|
||||
android:label="@string/app_name">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
@@ -40,6 +46,10 @@
|
||||
<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" />
|
||||
@@ -65,7 +75,7 @@
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".TempActivity"
|
||||
android:name=".QuickActionActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@style/TransparentTheme">
|
||||
@@ -87,7 +97,7 @@
|
||||
android:name=".TileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic"
|
||||
android:label="FlClash"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
@@ -98,14 +108,13 @@
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".BroadcastReceiver"
|
||||
android:name=".ServiceBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.intent.action.START" />
|
||||
<action android:name="${applicationId}.intent.action.STOP" />
|
||||
<action android:name="${applicationId}.intent.action.TOGGLE" />
|
||||
<action android:name="${applicationId}.intent.action.VPN_START_REQUESTED" />
|
||||
<action android:name="${applicationId}.intent.action.VPN_REVOKED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
@@ -113,4 +122,4 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.follow.clash.common.BroadcastAction
|
||||
import com.follow.clash.common.action
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BroadcastReceiver : BroadcastReceiver(),
|
||||
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
BroadcastAction.START.action -> {
|
||||
launch {
|
||||
State.handleStartServiceAction()
|
||||
}
|
||||
}
|
||||
|
||||
BroadcastAction.STOP.action -> {
|
||||
State.handleStopServiceAction()
|
||||
}
|
||||
|
||||
BroadcastAction.TOGGLE.action -> {
|
||||
launch {
|
||||
State.handleToggleAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,62 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val ICON_TTL_DAYS = 1L
|
||||
|
||||
val Application.sharedState: SharedState
|
||||
get() = try {
|
||||
val preferences = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||
val json = preferences.getString("flutter.sharedState", null)
|
||||
Gson().fromJson(json, SharedState::class.java) ?: SharedState()
|
||||
} catch (_: Exception) {
|
||||
SharedState()
|
||||
}
|
||||
|
||||
private var lastToast: Toast? = null
|
||||
|
||||
fun Application.showToast(text: String?) {
|
||||
if (text.isNullOrEmpty()) return
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
lastToast?.cancel()
|
||||
lastToast = Toast.makeText(this, text, Toast.LENGTH_LONG).apply {
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun PackageManager.getPackageIconPath(packageName: String): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
val cacheDir = GlobalState.application.cacheDir
|
||||
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
|
||||
return@withContext try {
|
||||
val pkgInfo = getPackageInfo(packageName, 0)
|
||||
val lastUpdateTime = pkgInfo.lastUpdateTime
|
||||
val lastUpdateTime = getPackageInfo(packageName, 0).lastUpdateTime
|
||||
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
|
||||
if (iconFile.exists() && !isExpired(iconFile)) {
|
||||
return@withContext iconFile.absolutePath
|
||||
}
|
||||
iconDir.listFiles()?.forEach { file ->
|
||||
if (file.name.startsWith(packageName + "_")) file.delete()
|
||||
}
|
||||
iconDir.listFiles { f -> f.name.startsWith("${packageName}_") }?.forEach(File::delete)
|
||||
|
||||
val icon = getApplicationIcon(packageName)
|
||||
saveDrawableToFile(icon, iconFile)
|
||||
iconFile.absolutePath
|
||||
@@ -47,17 +69,16 @@ suspend fun PackageManager.getPackageIconPath(packageName: String): String =
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveDrawableToFile(drawable: Drawable, file: File) {
|
||||
val bitmap = drawable.toBitmap()
|
||||
private suspend fun saveDrawableToFile(drawable: Drawable, file: File) {
|
||||
val bitmap = withContext(Dispatchers.Default) {
|
||||
drawable.toBitmap(width = 128, height = 128)
|
||||
}
|
||||
try {
|
||||
val format = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
|
||||
Bitmap.CompressFormat.WEBP_LOSSY
|
||||
}
|
||||
|
||||
else -> {
|
||||
Bitmap.CompressFormat.WEBP
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val format = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Bitmap.CompressFormat.WEBP_LOSSY
|
||||
} else {
|
||||
Bitmap.CompressFormat.WEBP
|
||||
}
|
||||
FileOutputStream(file).use { fos ->
|
||||
bitmap.compress(format, 90, fos)
|
||||
@@ -73,49 +94,12 @@ private fun isExpired(file: File): Boolean {
|
||||
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
|
||||
}
|
||||
|
||||
suspend fun <T> MethodChannel.awaitResult(
|
||||
method: String, arguments: Any? = null
|
||||
): T? = withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
invokeMethod(method, arguments, object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST") continuation.resume(result as T?)
|
||||
}
|
||||
|
||||
override fun error(code: String, message: String?, details: Any?) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
continuation.resume(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
|
||||
return plugins.get(T::class.java) as T?
|
||||
}
|
||||
|
||||
|
||||
fun <T> MethodChannel.invokeMethodOnMainThread(
|
||||
method: String, arguments: Any? = null, callback: ((Result<T>) -> Unit)? = null
|
||||
) {
|
||||
fun MethodChannel.invokeMethodOnMainThread(method: String, arguments: Any? = null) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
invokeMethod(method, arguments, object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST") callback?.invoke(Result.success(result as T))
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
val exception = Exception("MethodChannel error: $errorCode - $errorMessage")
|
||||
callback?.invoke(Result.failure(exception))
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
val exception = NotImplementedError("Method not implemented: $method")
|
||||
callback?.invoke(Result.failure(exception))
|
||||
}
|
||||
})
|
||||
invokeMethod(method, arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -4,8 +4,7 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import com.follow.clash.common.GlobalState
|
||||
|
||||
class Application : Application() {
|
||||
|
||||
class FlClashApplication : Application() {
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
GlobalState.init(this)
|
||||
@@ -1,37 +1,24 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : FlutterActivity(),
|
||||
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
lifecycleScope.launch {
|
||||
State.destroyServiceEngine()
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
State.flutterEngine = flutterEngine
|
||||
ServiceState.attachFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
State.flutterEngine = null
|
||||
flutterEngine?.let(ServiceState::detachFlutterEngine)
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.common.ServiceDelegate
|
||||
import com.follow.clash.common.intent
|
||||
import com.follow.clash.service.ICallbackInterface
|
||||
import com.follow.clash.service.IMessageInterface
|
||||
import com.follow.clash.service.IRemoteInterface
|
||||
import com.follow.clash.service.RemoteService
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
|
||||
object Service {
|
||||
private val delegate by lazy {
|
||||
ServiceDelegate<IRemoteInterface>(
|
||||
RemoteService::class.intent, ::handleServiceDisconnected
|
||||
) {
|
||||
IRemoteInterface.Stub.asInterface(it)
|
||||
}
|
||||
}
|
||||
|
||||
var onServiceDisconnected: ((String) -> Unit)? = null
|
||||
|
||||
private fun handleServiceDisconnected(message: String) {
|
||||
onServiceDisconnected?.let {
|
||||
it(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun bind() {
|
||||
delegate.bind()
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
delegate.unbind()
|
||||
}
|
||||
|
||||
suspend fun invokeAction(
|
||||
data: String, cb: (result: ByteArray?, isSuccess: Boolean) -> Unit
|
||||
): Result<Unit> {
|
||||
return delegate.useService {
|
||||
it.invokeAction(data, object : ICallbackInterface.Stub() {
|
||||
override fun onResult(result: ByteArray?, isSuccess: Boolean) {
|
||||
cb(result, isSuccess)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateNotificationParams(
|
||||
params: NotificationParams
|
||||
): Result<Unit> {
|
||||
return delegate.useService {
|
||||
it.updateNotificationParams(params)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setMessageCallback(
|
||||
cb: (result: String?) -> Unit
|
||||
): Result<Unit> {
|
||||
return delegate.useService {
|
||||
it.setMessageCallback(object : IMessageInterface.Stub() {
|
||||
override fun onResult(result: String?) {
|
||||
cb(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startService(options: VpnOptions, inApp: Boolean) {
|
||||
delegate.useService { it.startService(options, inApp) }
|
||||
}
|
||||
|
||||
suspend fun stopService() {
|
||||
delegate.useService { it.stopService() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
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 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 timeout = Runnable {
|
||||
lease.release { GlobalState.log("Broadcast handling timed out: $action") }
|
||||
}
|
||||
mainHandler.postDelayed(timeout, BROADCAST_TIMEOUT_MILLIS)
|
||||
GlobalState.launch {
|
||||
try {
|
||||
handleAction(action)
|
||||
} catch (error: Exception) {
|
||||
GlobalState.log("Unable to handle service broadcast $action: $error")
|
||||
} finally {
|
||||
mainHandler.removeCallbacks(timeout)
|
||||
lease.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleAction(action: String) {
|
||||
when (action) {
|
||||
BroadcastAction.VPN_START_REQUESTED.action -> ServiceState.handleStartAction()
|
||||
|
||||
BroadcastAction.VPN_REVOKED.action -> ServiceState.handleVpnRevokeAction()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BROADCAST_TIMEOUT_MILLIS = 9_000L
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.intent
|
||||
import com.follow.clash.core.Core
|
||||
import com.follow.clash.service.ManagedService
|
||||
import com.follow.clash.service.ProxyService
|
||||
import com.follow.clash.service.ServiceConfig
|
||||
import com.follow.clash.service.VpnService
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
object ServiceController {
|
||||
private val lock = Mutex()
|
||||
private var binding: ManagedServiceBinding? = null
|
||||
|
||||
@Volatile
|
||||
internal var runtime: ServiceRuntime? = null
|
||||
private set
|
||||
|
||||
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 {
|
||||
Core.invokeMethod(data) { result ->
|
||||
callback(result ?: ByteArray(0))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun quickSetup(
|
||||
initParams: String,
|
||||
setupParams: String,
|
||||
): Result<String> = runCatching {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
Core.quickSetup(initParams, setupParams) { result ->
|
||||
continuation.resume(result.orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setEventListener(callback: ((ByteArray) -> Unit)?): Result<Unit> = runCatching {
|
||||
Core.updateEventListener(callback)
|
||||
}
|
||||
|
||||
suspend fun start(options: VpnOptions): Boolean = lock.withLock {
|
||||
ServiceConfig.updateVpnOptions(options)
|
||||
val nextIntent = if (options.enable) {
|
||||
VpnService::class.intent
|
||||
} else {
|
||||
ProxyService::class.intent
|
||||
}
|
||||
|
||||
if (binding?.component != nextIntent.component) {
|
||||
if (binding != null) {
|
||||
tearDownServices()
|
||||
}
|
||||
lateinit var nextBinding: ManagedServiceBinding
|
||||
nextBinding = ManagedServiceBinding(nextIntent) { message ->
|
||||
handleServiceDisconnected(nextBinding, message)
|
||||
}
|
||||
binding = nextBinding
|
||||
nextBinding.bind().onFailure { error ->
|
||||
GlobalState.log("Unable to bind background service: $error")
|
||||
release()
|
||||
return@withLock false
|
||||
}
|
||||
}
|
||||
|
||||
val currentBinding = binding ?: return@withLock false
|
||||
val result = currentBinding.useService { service -> service.start() }
|
||||
if (result.isFailure) {
|
||||
GlobalState.log("Unable to start background service: ${result.exceptionOrNull()}")
|
||||
currentBinding.stopIfConnected()
|
||||
.onFailure { error ->
|
||||
GlobalState.log("Unable to clean up failed background service start: $error")
|
||||
}
|
||||
release()
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
Core.startListener()
|
||||
runtime = ServiceRuntime(
|
||||
vpn = options.enable,
|
||||
startedAtMillis = runtime?.startedAtMillis ?: System.currentTimeMillis(),
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
suspend fun stop() = lock.withLock {
|
||||
tearDownServices()
|
||||
release()
|
||||
}
|
||||
|
||||
private suspend fun tearDownServices() {
|
||||
binding?.useService { service -> service.stop() }
|
||||
?.onFailure { error ->
|
||||
GlobalState.log("Unable to stop background service: $error")
|
||||
}
|
||||
clearBinding()
|
||||
stopServices()
|
||||
}
|
||||
|
||||
// A service the system started itself — always-on VPN, or the sticky restart
|
||||
// after the process was killed — outlives every binding this process holds, so
|
||||
// unbinding alone leaves the tunnel up while the app reports it stopped.
|
||||
private fun stopServices() {
|
||||
listOf(VpnService::class.intent, ProxyService::class.intent).forEach { intent ->
|
||||
runCatching { GlobalState.application.stopService(intent) }
|
||||
.onFailure { error ->
|
||||
GlobalState.log("Unable to stop ${intent.component?.className}: $error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleServiceDisconnected(
|
||||
disconnectedBinding: ManagedServiceBinding,
|
||||
message: String,
|
||||
) {
|
||||
val token = ServiceState.captureRequestToken()
|
||||
GlobalState.launch {
|
||||
val wasCurrent = lock.withLock {
|
||||
if (binding !== disconnectedBinding) {
|
||||
return@withLock false
|
||||
}
|
||||
GlobalState.log("Background service disconnected: $message")
|
||||
release()
|
||||
true
|
||||
}
|
||||
if (wasCurrent) {
|
||||
ServiceState.handleServiceLost(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ManagedServiceBinding(
|
||||
private val intent: Intent,
|
||||
private val onDisconnected: (String) -> Unit,
|
||||
) : ServiceConnection {
|
||||
val component: ComponentName?
|
||||
get() = intent.component
|
||||
|
||||
private val serviceState = MutableStateFlow<Result<ManagedService>?>(null)
|
||||
|
||||
@Volatile
|
||||
private var isBound = false
|
||||
|
||||
suspend fun bind(): Result<Unit> = runCatching {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
serviceState.value = null
|
||||
isBound = GlobalState.application.bindService(
|
||||
intent,
|
||||
this@ManagedServiceBinding,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
)
|
||||
check(isBound) { "bindService() failed" }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <R> useService(
|
||||
connectionTimeoutMillis: Long = 5_000,
|
||||
block: suspend (ManagedService) -> R,
|
||||
): Result<R> = runCatching {
|
||||
val service = withTimeout(connectionTimeoutMillis) {
|
||||
serviceState.filterNotNull().first().getOrThrow()
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
block(service)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopIfConnected(): Result<Unit> = runCatching {
|
||||
val service = serviceState.value?.getOrNull() ?: return@runCatching
|
||||
withContext(Dispatchers.Default) {
|
||||
service.stop()
|
||||
}
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
serviceState.value = null
|
||||
if (!isBound) return
|
||||
isBound = false
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
runCatching {
|
||||
GlobalState.application.unbindService(this)
|
||||
}.onFailure { error ->
|
||||
GlobalState.log("Unable to unbind background service: $error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
runCatching {
|
||||
when (binder) {
|
||||
is VpnService.LocalBinder -> binder.service
|
||||
is ProxyService.LocalBinder -> binder.service
|
||||
null -> error("Binder is empty")
|
||||
else -> error("Unsupported service binder: ${binder.javaClass.name}")
|
||||
}
|
||||
}.onSuccess { service ->
|
||||
serviceState.value = Result.success(service)
|
||||
}.onFailure { error ->
|
||||
disconnect(error.message.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
disconnect("Service disconnected")
|
||||
}
|
||||
|
||||
override fun onBindingDied(name: ComponentName?) {
|
||||
disconnect("Service binding died")
|
||||
}
|
||||
|
||||
override fun onNullBinding(name: ComponentName?) {
|
||||
disconnect("Service returned an empty binder")
|
||||
}
|
||||
|
||||
private fun disconnect(message: String) {
|
||||
serviceState.value = Result.failure(IllegalStateException(message))
|
||||
onDisconnected(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.models.SharedState
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import kotlinx.coroutines.Deferred
|
||||
|
||||
object ServiceState {
|
||||
private val machine = ServiceStateMachine(AndroidServiceStateHost)
|
||||
|
||||
val runState = machine.runState
|
||||
|
||||
val isRunRequested: Boolean
|
||||
get() = machine.isRunRequested
|
||||
|
||||
fun attachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.attachFlutterEngine(engine)
|
||||
|
||||
fun detachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.detachFlutterEngine(engine)
|
||||
|
||||
suspend fun handleToggleAction() = machine.handleToggleAction()
|
||||
|
||||
suspend fun handleStartAction() = machine.handleStartAction()
|
||||
|
||||
suspend fun handleStopAction() = machine.handleStopAction()
|
||||
|
||||
suspend fun handleVpnRevokeAction() = machine.handleVpnRevokeAction()
|
||||
|
||||
suspend fun awaitRunTime(): Long = machine.awaitRunTime()
|
||||
|
||||
fun requestStart(): Deferred<Boolean> = machine.requestStart()
|
||||
|
||||
fun requestStop(): Deferred<Boolean> = machine.requestStop()
|
||||
|
||||
fun syncSharedState(state: SharedState) = machine.syncSharedState(state)
|
||||
|
||||
internal fun captureRequestToken(): RunRequest = machine.captureRequestToken()
|
||||
|
||||
internal suspend fun handleServiceLost(token: RunRequest) = machine.handleServiceLost(token)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
import com.follow.clash.plugins.ServicePlugin
|
||||
import com.follow.clash.plugins.TilePlugin
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
enum class RunState {
|
||||
START, PENDING, STOP
|
||||
}
|
||||
|
||||
|
||||
object State {
|
||||
|
||||
val runLock = Mutex()
|
||||
|
||||
var runTime: Long = 0
|
||||
|
||||
val runStateFlow: MutableStateFlow<RunState> = MutableStateFlow(RunState.STOP)
|
||||
var flutterEngine: FlutterEngine? = null
|
||||
var serviceFlutterEngine: FlutterEngine? = null
|
||||
|
||||
val appPlugin: AppPlugin?
|
||||
get() = flutterEngine?.plugin<AppPlugin>() ?: serviceFlutterEngine?.plugin<AppPlugin>()
|
||||
|
||||
val servicePlugin: ServicePlugin?
|
||||
get() = flutterEngine?.plugin<ServicePlugin>()
|
||||
?: serviceFlutterEngine?.plugin<ServicePlugin>()
|
||||
|
||||
val tilePlugin: TilePlugin?
|
||||
get() = flutterEngine?.plugin<TilePlugin>() ?: serviceFlutterEngine?.plugin<TilePlugin>()
|
||||
|
||||
suspend fun handleToggleAction() {
|
||||
var action: (suspend () -> Unit)?
|
||||
runLock.withLock {
|
||||
action = when (runStateFlow.value) {
|
||||
RunState.PENDING -> null
|
||||
RunState.START -> ::handleStopServiceAction
|
||||
RunState.STOP -> ::handleStartServiceAction
|
||||
}
|
||||
}
|
||||
action?.invoke()
|
||||
}
|
||||
|
||||
suspend fun handleStartServiceAction() {
|
||||
tilePlugin?.handleStart()
|
||||
if (flutterEngine != null) {
|
||||
return
|
||||
}
|
||||
startServiceWithEngine()
|
||||
}
|
||||
|
||||
fun handleStopServiceAction() {
|
||||
tilePlugin?.handleStop()
|
||||
if (flutterEngine != null || serviceFlutterEngine != null) {
|
||||
return
|
||||
}
|
||||
handleStopService()
|
||||
}
|
||||
|
||||
fun handleStartService() {
|
||||
if (appPlugin != null) {
|
||||
appPlugin?.requestNotificationsPermission {
|
||||
startService()
|
||||
}
|
||||
return
|
||||
}
|
||||
startService()
|
||||
}
|
||||
|
||||
suspend fun destroyServiceEngine() {
|
||||
runLock.withLock {
|
||||
withContext(Dispatchers.Main) {
|
||||
runCatching {
|
||||
serviceFlutterEngine?.destroy()
|
||||
serviceFlutterEngine = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startServiceWithEngine() {
|
||||
runLock.withLock {
|
||||
withContext(Dispatchers.Main) {
|
||||
serviceFlutterEngine = FlutterEngine(GlobalState.application)
|
||||
serviceFlutterEngine?.plugins?.add(ServicePlugin())
|
||||
serviceFlutterEngine?.plugins?.add(AppPlugin())
|
||||
serviceFlutterEngine?.plugins?.add(TilePlugin())
|
||||
val dartEntrypoint = DartExecutor.DartEntrypoint(
|
||||
FlutterInjector.instance().flutterLoader().findAppBundlePath(), "_service"
|
||||
)
|
||||
serviceFlutterEngine?.dartExecutor?.executeDartEntrypoint(dartEntrypoint)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun startService() {
|
||||
GlobalState.launch {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value == RunState.PENDING || runStateFlow.value == RunState.START) {
|
||||
return@launch
|
||||
}
|
||||
runStateFlow.tryEmit(RunState.PENDING)
|
||||
if (servicePlugin == null) {
|
||||
return@launch
|
||||
}
|
||||
val options = servicePlugin?.handleGetVpnOptions()
|
||||
if (options == null) {
|
||||
return@launch
|
||||
}
|
||||
appPlugin?.prepare(options.enable) {
|
||||
runTime = System.currentTimeMillis()
|
||||
Service.startService(options, true)
|
||||
runStateFlow.tryEmit(RunState.START)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun handleStopService() {
|
||||
GlobalState.launch {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value == RunState.PENDING || runStateFlow.value == RunState.STOP) {
|
||||
return@launch
|
||||
}
|
||||
runStateFlow.tryEmit(RunState.PENDING)
|
||||
Service.stopService()
|
||||
runStateFlow.tryEmit(RunState.STOP)
|
||||
runTime = 0
|
||||
}
|
||||
destroyServiceEngine()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.action
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TempActivity : Activity(),
|
||||
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
when (intent.action) {
|
||||
QuickAction.START.action -> {
|
||||
launch {
|
||||
State.handleStartServiceAction()
|
||||
}
|
||||
}
|
||||
|
||||
QuickAction.STOP.action -> {
|
||||
State.handleStopServiceAction()
|
||||
}
|
||||
|
||||
QuickAction.TOGGLE.action -> {
|
||||
launch {
|
||||
State.handleToggleAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.follow.clash
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.quickIntent
|
||||
import com.follow.clash.common.toPendingIntent
|
||||
@@ -13,49 +12,52 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TileService : TileService() {
|
||||
class TileService : android.service.quicksettings.TileService() {
|
||||
private var scope: CoroutineScope? = null
|
||||
private fun updateTile(runState: RunState) {
|
||||
if (qsTile != null) {
|
||||
qsTile.state = when (runState) {
|
||||
RunState.START -> Tile.STATE_ACTIVE
|
||||
RunState.PENDING -> Tile.STATE_UNAVAILABLE
|
||||
RunState.STOP -> Tile.STATE_INACTIVE
|
||||
}
|
||||
qsTile.updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
scope?.cancel()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
scope?.launch {
|
||||
State.runStateFlow.collect {
|
||||
updateTile(it)
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { scope ->
|
||||
scope.launch {
|
||||
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()
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
super.onStopListening()
|
||||
}
|
||||
|
||||
private fun updateTile(runState: RunState) {
|
||||
qsTile?.apply {
|
||||
state = when (runState) {
|
||||
RunState.STARTED -> Tile.STATE_ACTIVE
|
||||
RunState.STARTING, RunState.STOPPING -> Tile.STATE_UNAVAILABLE
|
||||
RunState.STOPPED -> Tile.STATE_INACTIVE
|
||||
}
|
||||
updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StartActivityAndCollapseDeprecated")
|
||||
private fun handleToggle() {
|
||||
private fun openQuickAction() {
|
||||
val intent = QuickAction.TOGGLE.quickIntent
|
||||
val pendingIntent = intent.toPendingIntent
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startActivityAndCollapse(pendingIntent)
|
||||
startActivityAndCollapse(intent.toPendingIntent)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
handleToggle()
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
scope?.cancel()
|
||||
super.onStopListening()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
package com.follow.clash.models
|
||||
|
||||
data class Package(
|
||||
data class InstalledPackage(
|
||||
val packageName: String,
|
||||
val label: String,
|
||||
val system: Boolean,
|
||||
@@ -1,8 +1,26 @@
|
||||
package com.follow.clash.models
|
||||
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class AppState(
|
||||
val currentProfileName: String,
|
||||
val stopText: String,
|
||||
val onlyStatisticsProxy: Boolean,
|
||||
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 currentProfileName: String = "FlClash",
|
||||
val stopText: String = "Stop",
|
||||
val onlyStatisticsProxy: Boolean = false,
|
||||
val showStopAction: Boolean = true,
|
||||
val vpnOptions: VpnOptions? = null,
|
||||
val setupParams: SetupParams? = null,
|
||||
)
|
||||
|
||||
data class SetupParams(
|
||||
@SerializedName("test-url")
|
||||
val testUrl: String,
|
||||
@SerializedName("selected-map")
|
||||
val selectedMap: Map<String, String>,
|
||||
@SerializedName("skip-cert-verify")
|
||||
val skipCertVerify: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.follow.clash.packages
|
||||
|
||||
/**
|
||||
* The prefixes in [CHINA_PACKAGE_REGEX] are matched *without* a trailing dot
|
||||
* boundary, and that is deliberate: `com.qihoo` has to reach `com.qihoo360.*`
|
||||
* and `com.ali` has to reach `com.aliyun.*` and `com.alimama.*`. Requiring a
|
||||
* separator would turn those into misses.
|
||||
*
|
||||
* The price is that unrelated names starting with the same letters match too.
|
||||
* [SKIPPED_PREFIXES] is where those come back out, and it *does* apply a dot
|
||||
* boundary, because there the entries are whole package roots.
|
||||
*/
|
||||
internal object ChinaPackageMatcher {
|
||||
|
||||
fun isSkipped(packageName: String): Boolean = SKIPPED_PREFIXES.any {
|
||||
packageName == it || packageName.startsWith("$it.")
|
||||
}
|
||||
|
||||
fun matchesKnownPrefix(name: String): Boolean = name.matches(CHINA_PACKAGE_REGEX)
|
||||
|
||||
fun classNameOf(descriptor: String): String = descriptor
|
||||
.removeSurrounding("L", ";")
|
||||
.replace('/', '.')
|
||||
.replace('$', '.')
|
||||
|
||||
private val SKIPPED_PREFIXES = listOf(
|
||||
"com.google",
|
||||
"com.android.chrome",
|
||||
"com.android.vending",
|
||||
"com.microsoft",
|
||||
"com.apple",
|
||||
"com.zhiliaoapp.musically",
|
||||
// Caught by the loose "com.mx" prefix, which targets Maxthon's
|
||||
// com.mx.browser. MX Player is unrelated.
|
||||
"com.mxtech",
|
||||
// Caught by the loose "com.stub" prefix, which targets the
|
||||
// com.stub.StubApp packer. StubHub is unrelated.
|
||||
"com.stubhub",
|
||||
)
|
||||
|
||||
private val CHINA_PACKAGE_REGEX = listOf(
|
||||
"com.tencent",
|
||||
"com.alibaba",
|
||||
"com.umeng",
|
||||
"com.qihoo",
|
||||
"com.ali",
|
||||
"com.alipay",
|
||||
"com.amap",
|
||||
"com.sina",
|
||||
"com.weibo",
|
||||
"com.vivo",
|
||||
"com.xiaomi",
|
||||
"com.huawei",
|
||||
"com.taobao",
|
||||
"com.secneo",
|
||||
"s.h.e.l.l",
|
||||
"com.stub",
|
||||
"com.kiwisec",
|
||||
"com.secshell",
|
||||
"com.wrapper",
|
||||
"cn.securitystack",
|
||||
"com.mogosec",
|
||||
"com.secoen",
|
||||
"com.netease",
|
||||
"com.mx",
|
||||
"com.qq.e",
|
||||
"com.baidu",
|
||||
"com.bytedance",
|
||||
"com.bugly",
|
||||
"com.miui",
|
||||
"com.oppo",
|
||||
"com.coloros",
|
||||
"com.iqoo",
|
||||
"com.meizu",
|
||||
"com.gionee",
|
||||
"cn.nubia",
|
||||
"com.oplus",
|
||||
"andes.oplus",
|
||||
"com.unionpay",
|
||||
"cn.wps",
|
||||
).joinToString("|", prefix = "(", postfix = ").*") { prefix ->
|
||||
Regex.escape(prefix)
|
||||
}.toRegex()
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.follow.clash.packages
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.ComponentInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
||||
import com.follow.clash.models.InstalledPackage
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
fun getChinaPackageNames(): List<String> = installedPackages
|
||||
.map { it.packageName }
|
||||
.filter(::isChinaPackage)
|
||||
|
||||
private fun loadPackages(): List<InstalledPackage> {
|
||||
val flags = PackageManager.GET_PERMISSIONS
|
||||
val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.getInstalledPackages(
|
||||
PackageManager.PackageInfoFlags.of(flags.toLong()),
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getInstalledPackages(flags)
|
||||
}
|
||||
return packages.asSequence()
|
||||
.filter { info ->
|
||||
info.packageName != appPackageName && info.packageName != ANDROID_PACKAGE_NAME
|
||||
}
|
||||
.map { info ->
|
||||
InstalledPackage(
|
||||
packageName = info.packageName,
|
||||
label = info.applicationInfo?.loadLabel(packageManager)?.toString()
|
||||
?: info.packageName,
|
||||
system = info.applicationInfo?.let { applicationInfo ->
|
||||
applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
|
||||
} == true,
|
||||
internet = info.requestedPermissions
|
||||
?.contains(Manifest.permission.INTERNET) == true,
|
||||
lastUpdateTime = info.lastUpdateTime,
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
|
||||
private fun isChinaPackage(packageName: String): Boolean {
|
||||
if (ChinaPackageMatcher.isSkipped(packageName)) {
|
||||
return false
|
||||
}
|
||||
if (ChinaPackageMatcher.matchesKnownPrefix(packageName)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val packageInfo = getPackageInfo(packageName)
|
||||
packageInfo.componentNames().any(ChinaPackageMatcher::matchesKnownPrefix) ||
|
||||
packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun getPackageInfo(packageName: String) = if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
) {
|
||||
packageManager.getPackageInfo(
|
||||
packageName,
|
||||
PackageManager.PackageInfoFlags.of(PACKAGE_INFO_FLAGS.toLong()),
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getPackageInfo(packageName, PACKAGE_INFO_FLAGS)
|
||||
}
|
||||
|
||||
private fun android.content.pm.PackageInfo.componentNames(): Sequence<String> = sequence {
|
||||
yieldAll(services.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
yieldAll(activities.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
yieldAll(receivers.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
yieldAll(providers.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
}
|
||||
|
||||
private fun scanArchive(sourcePath: String): Boolean = ZipFile(File(sourcePath)).use { archive ->
|
||||
if (archive.entries().asSequence().any { it.name.startsWith("firebase-") }) {
|
||||
return false
|
||||
}
|
||||
archive.entries().asSequence()
|
||||
.filter { entry ->
|
||||
entry.name.startsWith("classes") && entry.name.endsWith(".dex")
|
||||
}.any { entry ->
|
||||
if (entry.size > MAX_DEX_SIZE_BYTES) {
|
||||
return@any true
|
||||
}
|
||||
val dexFile = archive.getInputStream(entry).buffered().use { input ->
|
||||
DexBackedDexFile.fromInputStream(null, input)
|
||||
}
|
||||
dexFile.classes.any { clazz ->
|
||||
ChinaPackageMatcher.matchesKnownPrefix(
|
||||
ChinaPackageMatcher.classNameOf(clazz.type),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val GET_INSTALLED_APPS = "com.android.permission.GET_INSTALLED_APPS"
|
||||
|
||||
private const val ANDROID_PACKAGE_NAME = "android"
|
||||
private const val MAX_DEX_SIZE_BYTES = 15_000_000L
|
||||
|
||||
private val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
|
||||
PackageManager.GET_SERVICES or
|
||||
PackageManager.GET_RECEIVERS or
|
||||
PackageManager.GET_PROVIDERS
|
||||
}
|
||||
}
|
||||
@@ -3,124 +3,149 @@ package com.follow.clash.plugins
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.ComponentInfo
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.ContextCompat.getSystemService
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
||||
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.models.Package
|
||||
import com.follow.clash.packages.PackageResolver
|
||||
import com.follow.clash.showToast
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
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.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
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
|
||||
|
||||
companion object {
|
||||
const val VPN_PERMISSION_REQUEST_CODE = 1001
|
||||
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
|
||||
}
|
||||
private var activity: Activity? = null
|
||||
|
||||
private var activityRef: WeakReference<Activity>? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
private val activityResultListener =
|
||||
PluginRegistry.ActivityResultListener(::onActivityResult)
|
||||
|
||||
private val permissionsResultListener =
|
||||
PluginRegistry.RequestPermissionsResultListener(::onRequestPermissionsResultListener)
|
||||
|
||||
private lateinit var channel: MethodChannel
|
||||
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private var vpnPrepareCallback: (suspend () -> Unit)? = null
|
||||
private val vpnPrepareCallback = PendingCallback<Boolean>()
|
||||
|
||||
private var requestNotificationCallback: (() -> Unit)? = null
|
||||
|
||||
private val packages = mutableListOf<Package>()
|
||||
private val requestNotificationCallback = PendingCallback<Boolean>()
|
||||
|
||||
private val skipPrefixList = listOf(
|
||||
"com.google",
|
||||
"com.android.chrome",
|
||||
"com.android.vending",
|
||||
"com.microsoft",
|
||||
"com.apple",
|
||||
"com.zhiliaoapp.musically", // Banned by China
|
||||
)
|
||||
private val requestInstalledAppsCallback = PendingCallback<Boolean>()
|
||||
|
||||
private val chinaAppPrefixList = 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"
|
||||
)
|
||||
private val requestLocalNetworkCallback = PendingCallback<Boolean>()
|
||||
|
||||
private val chinaAppRegex by lazy {
|
||||
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
|
||||
private var isRequestingNotificationPermission = false
|
||||
|
||||
private var isRequestingLocalNetworkPermission = false
|
||||
|
||||
private var skipLocalNetworkPermissionRequest = false
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
private val packageResolver by lazy {
|
||||
PackageResolver(
|
||||
GlobalState.application.packageManager,
|
||||
GlobalState.application.packageName,
|
||||
)
|
||||
}
|
||||
|
||||
private var isBlockNotification: Boolean = false
|
||||
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" -> {
|
||||
activityRef?.get()?.moveTaskToBack(true)
|
||||
activity?.moveTaskToBack(true)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
@@ -131,20 +156,33 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
|
||||
"initShortcuts" -> {
|
||||
initShortcuts(call.arguments as String)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"getPackages" -> {
|
||||
scope.launch {
|
||||
result.success(getPackagesToJson())
|
||||
val labels = shortcutLabels(call.arguments)
|
||||
if (labels == null) {
|
||||
result.error(
|
||||
"INVALID_ARGUMENT",
|
||||
"Shortcut labels must map every quick action to a string",
|
||||
null,
|
||||
)
|
||||
} else {
|
||||
initShortcuts(labels)
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
|
||||
"getChinaPackageNames" -> {
|
||||
scope.launch {
|
||||
result.success(getChinaPackageNames())
|
||||
}
|
||||
"getPackages" -> reply(result) {
|
||||
gson.toJson(packageResolver.installedPackages)
|
||||
}
|
||||
|
||||
"getChinaPackageNames" -> reply(result) {
|
||||
gson.toJson(packageResolver.getChinaPackageNames())
|
||||
}
|
||||
|
||||
"isInstalledAppsPermissionGranted" -> reply(result) {
|
||||
packageResolver.hasInstalledAppsPermission()
|
||||
}
|
||||
|
||||
"requestInstalledAppsPermission" -> {
|
||||
requestInstalledAppsPermission { granted -> result.success(granted) }
|
||||
}
|
||||
|
||||
"getPackageIcon" -> {
|
||||
@@ -153,266 +191,352 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
|
||||
"tip" -> {
|
||||
val message = call.argument<String>("message")
|
||||
tip(message)
|
||||
GlobalState.application.showToast(message)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"openAppSettings" -> {
|
||||
result.success(openAppSettings())
|
||||
}
|
||||
|
||||
"getLastExitInfo" -> reply(result) {
|
||||
GlobalState.lastExitInfo()
|
||||
}
|
||||
|
||||
else -> {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGetPackageIcon(call: MethodCall, result: Result) {
|
||||
scope.launch {
|
||||
val packageName = call.argument<String>("packageName")
|
||||
if (packageName == null) {
|
||||
result.success("")
|
||||
return@launch
|
||||
private fun handleGetPackageIcon(call: MethodCall, result: Result) = reply(result) {
|
||||
val packageName = call.argument<String>("packageName") ?: ""
|
||||
GlobalState.application.packageManager.getPackageIconPath(packageName)
|
||||
}
|
||||
|
||||
// A throw inside the plugin scope has no handler and ends the process;
|
||||
// the Dart caller gets a PlatformException to handle instead.
|
||||
private fun reply(result: Result, block: suspend () -> Any?) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
result.success(block())
|
||||
} catch (error: Exception) {
|
||||
GlobalState.log("Platform call failed: $error")
|
||||
result.error("PLATFORM_ERROR", error.toString(), null)
|
||||
}
|
||||
val path = GlobalState.application.packageManager.getPackageIconPath(packageName)
|
||||
result.success(path)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
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
|
||||
}
|
||||
ShortcutManagerCompat.setDynamicShortcuts(
|
||||
GlobalState.application, listOf(shortcut)
|
||||
)
|
||||
}
|
||||
|
||||
private fun tip(message: String?) {
|
||||
Toast.makeText(GlobalState.application, message, Toast.LENGTH_LONG).show()
|
||||
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()
|
||||
}
|
||||
}
|
||||
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 {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = "package:${GlobalState.application.packageName}".toUri()
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@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 == activityRef?.get()?.taskId
|
||||
it.taskInfo?.taskId == taskId
|
||||
} else {
|
||||
it.taskInfo.id == activityRef?.get()?.taskId
|
||||
it.taskInfo?.id == taskId
|
||||
}
|
||||
}
|
||||
|
||||
when (value) {
|
||||
true -> task?.setExcludeFromRecents(value)
|
||||
false -> task?.setExcludeFromRecents(value)
|
||||
null -> task?.setExcludeFromRecents(false)
|
||||
}
|
||||
task?.setExcludeFromRecents(value ?: false)
|
||||
}
|
||||
|
||||
|
||||
private fun getPackages(): List<Package> {
|
||||
val packageManager = GlobalState.application.packageManager
|
||||
if (packages.isNotEmpty()) return packages
|
||||
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA or PackageManager.GET_PERMISSIONS)
|
||||
?.filter {
|
||||
it.packageName != GlobalState.application.packageName && it.packageName != "android"
|
||||
}?.map {
|
||||
Package(
|
||||
packageName = it.packageName,
|
||||
label = it.applicationInfo?.loadLabel(packageManager).toString(),
|
||||
system = (it.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM)) != 0,
|
||||
lastUpdateTime = it.lastUpdateTime,
|
||||
internet = it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|
||||
)
|
||||
}?.let { packages.addAll(it) }
|
||||
return packages
|
||||
}
|
||||
|
||||
private suspend fun getPackagesToJson(): String {
|
||||
return withContext(Dispatchers.Default) {
|
||||
Gson().toJson(getPackages())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getChinaPackageNames(): String {
|
||||
return withContext(Dispatchers.Default) {
|
||||
val packages: List<String> =
|
||||
getPackages().map { it.packageName }.filter { isChinaPackage(it) }
|
||||
Gson().toJson(packages)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestNotificationsPermission(callBack: () -> Unit) {
|
||||
requestNotificationCallback = callBack
|
||||
fun requestNotificationPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestNotificationCallback.replace(callback, supersededValue = false)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val permission = ContextCompat.checkSelfPermission(
|
||||
GlobalState.application, Manifest.permission.POST_NOTIFICATIONS
|
||||
GlobalState.application,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
if (permission == PackageManager.PERMISSION_GRANTED || isBlockNotification) {
|
||||
invokeRequestNotificationCallback()
|
||||
return
|
||||
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
|
||||
invokeRequestNotificationCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
activityRef?.get()?.let {
|
||||
if (isRequestingNotificationPermission) {
|
||||
return@onMainThread
|
||||
}
|
||||
isRequestingNotificationPermission = true
|
||||
activity?.let {
|
||||
ActivityCompat.requestPermissions(
|
||||
it,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
return
|
||||
} else {
|
||||
invokeRequestNotificationCallback()
|
||||
} ?: invokeRequestNotificationCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
|
||||
invokeRequestNotificationCallback(true)
|
||||
}
|
||||
|
||||
fun invokeRequestNotificationCallback() {
|
||||
requestNotificationCallback?.invoke()
|
||||
requestNotificationCallback = null
|
||||
private fun invokeRequestNotificationCallback(shouldStart: Boolean) {
|
||||
isRequestingNotificationPermission = false
|
||||
requestNotificationCallback.resolve(shouldStart)
|
||||
}
|
||||
|
||||
fun prepare(needPrepare: Boolean, callBack: (suspend () -> Unit)) {
|
||||
vpnPrepareCallback = callBack
|
||||
fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestLocalNetworkCallback.replace(callback, supersededValue = false)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN || hasLocalNetworkPermission()) {
|
||||
invokeRequestLocalNetworkCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
if (skipLocalNetworkPermissionRequest) {
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
return@onMainThread
|
||||
}
|
||||
if (isRequestingLocalNetworkPermission) {
|
||||
return@onMainThread
|
||||
}
|
||||
val activity = activity
|
||||
if (activity == null) {
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
return@onMainThread
|
||||
}
|
||||
isRequestingLocalNetworkPermission = true
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(Manifest.permission.ACCESS_LOCAL_NETWORK),
|
||||
LOCAL_NETWORK_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasLocalNetworkPermission(): Boolean = ContextCompat.checkSelfPermission(
|
||||
GlobalState.application,
|
||||
Manifest.permission.ACCESS_LOCAL_NETWORK,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun invokeRequestLocalNetworkCallback(granted: Boolean) {
|
||||
isRequestingLocalNetworkPermission = false
|
||||
requestLocalNetworkCallback.resolve(granted)
|
||||
}
|
||||
|
||||
private fun requestInstalledAppsPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestInstalledAppsCallback.replace(callback, supersededValue = false)
|
||||
val activity = activity
|
||||
if (packageResolver.hasInstalledAppsPermission()) {
|
||||
invokeRequestInstalledAppsCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
if (activity == null) {
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
return@onMainThread
|
||||
}
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(PackageResolver.GET_INSTALLED_APPS),
|
||||
INSTALLED_APPS_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun invokeRequestInstalledAppsCallback(granted: Boolean) {
|
||||
if (granted) {
|
||||
packageResolver.invalidate()
|
||||
}
|
||||
requestInstalledAppsCallback.resolve(granted)
|
||||
}
|
||||
|
||||
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) = onMainThread {
|
||||
vpnPrepareCallback.replace(callback, supersededValue = false)
|
||||
if (!needPrepare) {
|
||||
invokeVpnPrepareCallback()
|
||||
return
|
||||
invokeVpnPrepareCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
val intent = VpnService.prepare(GlobalState.application)
|
||||
if (intent != null) {
|
||||
activityRef?.get()?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
||||
return
|
||||
}
|
||||
invokeVpnPrepareCallback()
|
||||
}
|
||||
|
||||
fun invokeVpnPrepareCallback() {
|
||||
GlobalState.launch {
|
||||
vpnPrepareCallback?.invoke()
|
||||
vpnPrepareCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun isChinaPackage(packageName: String): Boolean {
|
||||
val packageManager = GlobalState.application.packageManager ?: return false
|
||||
skipPrefixList.forEach {
|
||||
if (packageName == it || packageName.startsWith("$it.")) return false
|
||||
}
|
||||
val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
||||
} else {
|
||||
PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
||||
}
|
||||
if (packageName.matches(chinaAppRegex)) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.getPackageInfo(
|
||||
packageName, PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong())
|
||||
)
|
||||
val activity = activity
|
||||
if (activity == null) {
|
||||
invokeVpnPrepareCallback(false)
|
||||
} else {
|
||||
packageManager.getPackageInfo(
|
||||
packageName, packageManagerFlags
|
||||
)
|
||||
@Suppress("DEPRECATION")
|
||||
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
mutableListOf<ComponentInfo>().apply {
|
||||
packageInfo.services?.let { addAll(it) }
|
||||
packageInfo.activities?.let { addAll(it) }
|
||||
packageInfo.receivers?.let { addAll(it) }
|
||||
packageInfo.providers?.let { addAll(it) }
|
||||
}.forEach {
|
||||
if (it.name.matches(chinaAppRegex)) return true
|
||||
}
|
||||
packageInfo.applicationInfo?.publicSourceDir?.let {
|
||||
ZipFile(File(it)).use {
|
||||
for (packageEntry in it.entries()) {
|
||||
if (packageEntry.name.startsWith("firebase-")) return false
|
||||
}
|
||||
for (packageEntry in it.entries()) {
|
||||
if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith(
|
||||
".dex"
|
||||
))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (packageEntry.size > 15000000) {
|
||||
return true
|
||||
}
|
||||
val input = it.getInputStream(packageEntry).buffered()
|
||||
val dexFile = try {
|
||||
DexBackedDexFile.fromInputStream(null, input)
|
||||
} catch (e: Exception) {
|
||||
return false
|
||||
}
|
||||
for (clazz in dexFile.classes) {
|
||||
val clazzName =
|
||||
clazz.type.substring(1, clazz.type.length - 1).replace("/", ".")
|
||||
.replace("$", ".")
|
||||
if (clazzName.matches(chinaAppRegex)) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
return false
|
||||
return@onMainThread
|
||||
}
|
||||
return false
|
||||
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)
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
scope = CoroutineScope(Dispatchers.Default)
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
channel =
|
||||
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
|
||||
channel.setMethodCallHandler(this)
|
||||
watchPackageChanges(flutterPluginBinding.applicationContext)
|
||||
}
|
||||
|
||||
private fun watchPackageChanges(context: Context) {
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
context.registerReceiverCompat(packageChangeReceiver, filter)
|
||||
packageChangeContext = context
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
packageChangeContext?.unregisterReceiver(packageChangeReceiver)
|
||||
packageChangeContext = null
|
||||
channel.setMethodCallHandler(null)
|
||||
scope.cancel()
|
||||
invokeVpnPrepareCallback(false)
|
||||
invokeRequestNotificationCallback(false)
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityRef = WeakReference(binding.activity)
|
||||
binding.addActivityResultListener(::onActivityResult)
|
||||
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
|
||||
attachToActivity(binding)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activityRef = null
|
||||
detachFromActivity()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activityRef = WeakReference(binding.activity)
|
||||
attachToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
channel.invokeMethod("exit", null)
|
||||
activityRef = null
|
||||
detachFromActivity()
|
||||
invokeVpnPrepareCallback(false)
|
||||
invokeRequestNotificationCallback(false)
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
}
|
||||
|
||||
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
|
||||
if (resultCode == FlutterActivity.RESULT_OK) {
|
||||
invokeVpnPrepareCallback()
|
||||
}
|
||||
if (requestCode != VPN_PERMISSION_REQUEST_CODE) {
|
||||
return false
|
||||
}
|
||||
invokeVpnPrepareCallback(resultCode == Activity.RESULT_OK)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onRequestPermissionsResultListener(
|
||||
requestCode: Int, permissions: Array<String>, grantResults: IntArray
|
||||
): Boolean {
|
||||
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
|
||||
isBlockNotification = true
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray,
|
||||
): Boolean = when (requestCode) {
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE -> {
|
||||
skipNotificationPermissionRequest = true
|
||||
invokeRequestNotificationCallback(true)
|
||||
true
|
||||
}
|
||||
invokeRequestNotificationCallback()
|
||||
return true
|
||||
|
||||
INSTALLED_APPS_PERMISSION_REQUEST_CODE -> {
|
||||
invokeRequestInstalledAppsCallback(
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
LOCAL_NETWORK_PERMISSION_REQUEST_CODE -> {
|
||||
skipLocalNetworkPermissionRequest = true
|
||||
invokeRequestLocalNetworkCallback(
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,157 +1,145 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import com.follow.clash.RunState
|
||||
import com.follow.clash.Service
|
||||
import com.follow.clash.State
|
||||
import com.follow.clash.awaitResult
|
||||
import com.follow.clash.ServiceController
|
||||
import com.follow.clash.ServiceState
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.common.formatString
|
||||
import com.follow.clash.invokeMethodOnMainThread
|
||||
import com.follow.clash.models.AppState
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
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
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
||||
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
private lateinit var flutterMethodChannel: MethodChannel
|
||||
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(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
flutterMethodChannel = MethodChannel(
|
||||
flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/service"
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
messenger = binding.binaryMessenger
|
||||
channel = MethodChannel(messenger, SERVICE_CHANNEL)
|
||||
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(),
|
||||
)
|
||||
flutterMethodChannel.setMethodCallHandler(this)
|
||||
coreChannel.setMethodCallHandler(::onCoreMethodCall)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
flutterMethodChannel.setMethodCallHandler(null)
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
coreChannel.setMethodCallHandler(null)
|
||||
scope.cancel()
|
||||
ServiceController.setEventListener(null)
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
|
||||
"init" -> {
|
||||
handleInit(result)
|
||||
}
|
||||
|
||||
"shutdown" -> {
|
||||
handleShutdown(result)
|
||||
}
|
||||
|
||||
"invokeAction" -> {
|
||||
handleInvokeAction(call, result)
|
||||
}
|
||||
|
||||
"getRunTime" -> {
|
||||
handleGetRunTime(result)
|
||||
}
|
||||
|
||||
"syncState" -> {
|
||||
handleSyncState(call, result)
|
||||
}
|
||||
|
||||
"start" -> {
|
||||
handleStart(result)
|
||||
}
|
||||
|
||||
"stop" -> {
|
||||
handleStop(result)
|
||||
}
|
||||
|
||||
else -> {
|
||||
result.notImplemented()
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"init" -> initialize(result)
|
||||
"shutdown" -> shutdown(result)
|
||||
"getRunTime" -> getRunTime(result)
|
||||
"syncState" -> syncState(call, result)
|
||||
"start" -> start(call, result)
|
||||
"stop" -> stop(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInvokeAction(call: MethodCall, result: MethodChannel.Result) {
|
||||
launch {
|
||||
val data = call.arguments<String>()!!
|
||||
val res = mutableListOf<ByteArray>()
|
||||
Service.invokeAction(data) { byteArray, isSuccess ->
|
||||
res.add(byteArray ?: byteArrayOf())
|
||||
if (isSuccess) {
|
||||
result.success(res.formatString())
|
||||
}
|
||||
}
|
||||
private fun onCoreMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"invokeMethod" -> invokeMethod(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleShutdown(result: MethodChannel.Result) {
|
||||
Service.unbind()
|
||||
private fun initialize(result: MethodChannel.Result) {
|
||||
ServiceController.setEventListener(::sendEvent)
|
||||
.onSuccess { result.success("") }
|
||||
.onFailure { error -> result.success(error.message.orEmpty()) }
|
||||
}
|
||||
|
||||
private fun shutdown(result: MethodChannel.Result) {
|
||||
ServiceController.setEventListener(null)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun handleStart(result: MethodChannel.Result) {
|
||||
State.handleStartService()
|
||||
private fun invokeMethod(call: MethodCall, result: MethodChannel.Result) {
|
||||
val data = call.arguments as? String
|
||||
if (data == null) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRunTime(result: MethodChannel.Result) {
|
||||
scope.launch {
|
||||
result.success(ServiceState.awaitRunTime())
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncState(call: MethodCall, result: MethodChannel.Result) {
|
||||
val data = call.arguments as? String
|
||||
val state = runCatching {
|
||||
gson.fromJson(data, SharedState::class.java)
|
||||
}.getOrNull()
|
||||
if (state == null) {
|
||||
result.success("Invalid shared state")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
ServiceState.syncSharedState(state)
|
||||
result.success("")
|
||||
}
|
||||
}
|
||||
|
||||
private fun start(call: MethodCall, result: MethodChannel.Result) {
|
||||
commandRevision = call.arguments as? Int ?: commandRevision
|
||||
ServiceState.requestStart()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun handleStop(result: MethodChannel.Result) {
|
||||
State.handleStopService()
|
||||
private fun stop(call: MethodCall, result: MethodChannel.Result) {
|
||||
commandRevision = call.arguments as? Int ?: commandRevision
|
||||
ServiceState.requestStop()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
suspend fun handleGetVpnOptions(): VpnOptions? {
|
||||
val res = flutterMethodChannel.awaitResult<String>("getVpnOptions", null)
|
||||
return Gson().fromJson(res, VpnOptions::class.java)
|
||||
}
|
||||
|
||||
val semaphore = Semaphore(10)
|
||||
|
||||
fun handleSendEvent(value: String?) {
|
||||
launch(Dispatchers.Main) {
|
||||
semaphore.withPermit {
|
||||
flutterMethodChannel.invokeMethod("event", value)
|
||||
// Read on the platform thread, where start and stop arrive: revision and intent are one instant.
|
||||
fun notifyStopped() {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
if (!ServiceState.isRunRequested) {
|
||||
channel.invokeMethod("stopped", commandRevision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onServiceDisconnected(message: String) {
|
||||
State.runStateFlow.tryEmit(RunState.STOP)
|
||||
flutterMethodChannel.invokeMethodOnMainThread<Any>("crash", message)
|
||||
}
|
||||
|
||||
private fun handleSyncState(call: MethodCall, result: MethodChannel.Result) {
|
||||
launch {
|
||||
val data = call.arguments<String>()!!
|
||||
val params = Gson().fromJson(data, AppState::class.java)
|
||||
Service.updateNotificationParams(
|
||||
NotificationParams(
|
||||
title = params.currentProfileName,
|
||||
stopText = params.stopText,
|
||||
onlyStatisticsProxy = params.onlyStatisticsProxy
|
||||
)
|
||||
).onSuccess {
|
||||
result.success("")
|
||||
}.onFailure {
|
||||
result.success(it.message)
|
||||
}
|
||||
private fun sendEvent(value: ByteArray) {
|
||||
val message = StandardMethodCodec.INSTANCE.encodeMethodCall(MethodCall("event", value))
|
||||
scope.launch(Dispatchers.Main) {
|
||||
messenger.send(SERVICE_CHANNEL, message)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleInit(result: MethodChannel.Result) {
|
||||
Service.bind()
|
||||
launch {
|
||||
Service.setMessageCallback {
|
||||
handleSendEvent(it)
|
||||
}.onSuccess {
|
||||
result.success("")
|
||||
}.onFailure {
|
||||
result.success(it.message)
|
||||
}
|
||||
|
||||
}
|
||||
Service.onServiceDisconnected = ::onServiceDisconnected
|
||||
private companion object {
|
||||
val SERVICE_CHANNEL = "${Components.PACKAGE_NAME}/service"
|
||||
val CORE_CHANNEL = "${Components.PACKAGE_NAME}/core"
|
||||
}
|
||||
|
||||
private fun handleGetRunTime(result: MethodChannel.Result) {
|
||||
return result.success(State.runTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
|
||||
private lateinit var channel: MethodChannel
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
@@ -21,13 +20,10 @@ class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
}
|
||||
|
||||
fun handleStart() {
|
||||
channel.invokeMethodOnMainThread<Any>("start", null)
|
||||
channel.invokeMethodOnMainThread("start")
|
||||
}
|
||||
|
||||
fun handleStop() {
|
||||
channel.invokeMethodOnMainThread<Any>("stop", null)
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.notImplemented()
|
||||
}
|
||||
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<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.924"
|
||||
android:scaleY="0.924"
|
||||
android:translateX="9.12"
|
||||
android:translateY="9.12">
|
||||
<group android:scaleX="0.63461536"
|
||||
android:scaleY="0.63461536"
|
||||
android:translateX="45.96154"
|
||||
android:translateY="43.846153">
|
||||
<path
|
||||
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"
|
||||
android:fillColor="#6666FB"/>
|
||||
<path
|
||||
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"
|
||||
android:fillColor="#336AB6"/>
|
||||
<path
|
||||
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"
|
||||
android:fillColor="#5CA8E9"/>
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="240"
|
||||
android:viewportHeight="240">
|
||||
<group android:scaleX="0.924"
|
||||
android:scaleY="0.924"
|
||||
android:translateX="9.12"
|
||||
android:translateY="9.12">
|
||||
<group android:scaleX="0.63461536"
|
||||
android:scaleY="0.63461536"
|
||||
android:translateX="45.96154"
|
||||
android:translateY="43.846153">
|
||||
<path
|
||||
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"
|
||||
android:fillColor="#6666FB"/>
|
||||
<path
|
||||
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"
|
||||
android:fillColor="#336AB6"/>
|
||||
<path
|
||||
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"
|
||||
android:fillColor="#5CA8E9"/>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<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>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?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.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 782 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="shortcut_background">#6666FB</color>
|
||||
</resources>
|
||||
@@ -1,6 +0,0 @@
|
||||
<paths>
|
||||
<files-path
|
||||
name="files"
|
||||
path="."/>
|
||||
</paths>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:ignore="AcceptsUserCertificates">
|
||||
<base-config>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -1,7 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
+16
-11
@@ -1,19 +1,10 @@
|
||||
buildscript {
|
||||
dependencies {
|
||||
classpath(libs.build.kotlin)
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.library") apply false
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
@@ -24,11 +15,25 @@ subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
gradle.projectsEvaluated {
|
||||
allprojects {
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
sourceCompatibility = JavaVersion.VERSION_17.toString()
|
||||
targetCompatibility = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -10,17 +9,7 @@ android {
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -28,6 +17,10 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
// Unit tests live under android/tests/ instead of each module's src/test.
|
||||
getByName("test").java.setSrcDirs(listOf("../tests/common"))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -39,4 +32,5 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.gson)
|
||||
}
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* A `BroadcastReceiver.goAsync()` path holds the broadcast open until its
|
||||
* `PendingResult` is finished, and finishing twice throws. Normal completion and
|
||||
* the timeout watchdog both race to release it, so the winner is decided here
|
||||
* rather than by whichever callback happens to run first.
|
||||
*
|
||||
* Releasing the lease says only that Android may stop waiting for this receiver.
|
||||
* It does not cancel, reverse, or otherwise redefine the work the broadcast
|
||||
* started, which keeps running under its own owner.
|
||||
*/
|
||||
class BroadcastLease(private val release: () -> Unit) {
|
||||
private val released = AtomicBoolean(false)
|
||||
|
||||
val isReleased: Boolean
|
||||
get() = released.get()
|
||||
|
||||
fun release(onRelease: () -> Unit = {}): Boolean {
|
||||
if (!released.compareAndSet(false, true)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
onRelease()
|
||||
} finally {
|
||||
release.invoke()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import android.content.ComponentName
|
||||
object Components {
|
||||
const val PACKAGE_NAME = "com.follow.clash"
|
||||
|
||||
val MAIN_ACTIVITY =
|
||||
val mainActivity =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.MainActivity")
|
||||
|
||||
val TEMP_ACTIVITY =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.TempActivity")
|
||||
val quickActionActivity =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.QuickActionActivity")
|
||||
|
||||
val BROADCAST_RECEIVER =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.BroadcastReceiver")
|
||||
}
|
||||
val serviceBroadcastReceiver =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.ServiceBroadcastReceiver")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.follow.clash.common
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
|
||||
enum class QuickAction {
|
||||
STOP,
|
||||
START,
|
||||
@@ -10,9 +9,8 @@ enum class QuickAction {
|
||||
}
|
||||
|
||||
enum class BroadcastAction {
|
||||
START,
|
||||
STOP,
|
||||
TOGGLE,
|
||||
VPN_START_REQUESTED,
|
||||
VPN_REVOKED,
|
||||
}
|
||||
|
||||
enum class AccessControlMode {
|
||||
@@ -21,4 +19,4 @@ enum class AccessControlMode {
|
||||
|
||||
@SerializedName("rejectSelected")
|
||||
REJECT_SELECTED,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,57 +9,33 @@ 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.ServiceConnection
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.RemoteException
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.retryWhen
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.nio.charset.Charset
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
//fun Context.startForegroundServiceCompat(intent: Intent?) {
|
||||
// if (Build.VERSION.SDK_INT >= 26) {
|
||||
// startForegroundService(intent)
|
||||
// } else {
|
||||
// startService(intent)
|
||||
// }
|
||||
//}
|
||||
|
||||
val KClass<*>.intent: Intent
|
||||
get() = Intent(GlobalState.application, this.java)
|
||||
|
||||
fun Service.startForegroundCompat(id: Int, notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(id, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||
} else {
|
||||
startForeground(id, notification)
|
||||
}
|
||||
}
|
||||
|
||||
val ComponentName.intent: Intent
|
||||
get() = Intent().apply {
|
||||
setComponent(this@intent)
|
||||
setPackage(GlobalState.packageName)
|
||||
component = this@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.TEMP_ACTIVITY.intent.apply {
|
||||
get() = Components.quickActionActivity.intent.apply {
|
||||
action = this@quickIntent.action
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
||||
}
|
||||
@@ -67,59 +43,71 @@ val QuickAction.quickIntent: Intent
|
||||
val BroadcastAction.action: String
|
||||
get() = "${GlobalState.application.packageName}.intent.action.${this.name}"
|
||||
|
||||
val BroadcastAction.quickIntent: Intent
|
||||
get() = Components.BROADCAST_RECEIVER.intent.apply {
|
||||
action = this@quickIntent.action
|
||||
}
|
||||
|
||||
fun BroadcastAction.sendBroadcast() {
|
||||
val intent = Intent().apply {
|
||||
action = this@sendBroadcast.action
|
||||
Log.d("[sendBroadcast]", "$action")
|
||||
setPackage(GlobalState.packageName)
|
||||
val broadcastAction = action
|
||||
val intent = Intent(broadcastAction).apply {
|
||||
component = Components.serviceBroadcastReceiver
|
||||
}
|
||||
GlobalState.application.sendBroadcast(
|
||||
intent, GlobalState.RECEIVE_BROADCASTS_PERMISSIONS
|
||||
intent,
|
||||
GlobalState.receiveBroadcastPermission,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val Intent.toPendingIntent: PendingIntent
|
||||
get() = PendingIntent.getActivity(
|
||||
GlobalState.application,
|
||||
0,
|
||||
this,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
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,
|
||||
"SERVICE_CHANNEL",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
manager?.createNotificationChannel(channel)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
GlobalState.NOTIFICATION_ID,
|
||||
notification,
|
||||
FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
||||
)
|
||||
} else {
|
||||
startForeground(GlobalState.NOTIFICATION_ID, notification)
|
||||
}
|
||||
startForegroundCompat(GlobalState.NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
fun Context.registerReceiverCompat(
|
||||
receiver: BroadcastReceiver,
|
||||
filter: IntentFilter,
|
||||
exported: Boolean = false,
|
||||
) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED)
|
||||
registerReceiver(
|
||||
receiver,
|
||||
filter,
|
||||
if (exported) RECEIVER_EXPORTED else RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
} else {
|
||||
registerReceiver(receiver, filter)
|
||||
}
|
||||
|
||||
fun Context.receiveBroadcastFlow(
|
||||
exported: Boolean = false,
|
||||
configure: IntentFilter.() -> Unit,
|
||||
): Flow<Intent> = callbackFlow {
|
||||
val filter = IntentFilter().apply(configure)
|
||||
@@ -129,110 +117,6 @@ fun Context.receiveBroadcastFlow(
|
||||
trySend(intent)
|
||||
}
|
||||
}
|
||||
registerReceiverCompat(receiver, filter)
|
||||
registerReceiverCompat(receiver, filter, exported)
|
||||
awaitClose { unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
|
||||
inline fun <reified T : IBinder> Context.bindServiceFlow(
|
||||
intent: Intent,
|
||||
flags: Int = Context.BIND_AUTO_CREATE,
|
||||
maxRetries: Int = 5,
|
||||
retryDelayMillis: Long = 200L
|
||||
): Flow<Pair<IBinder?, String>> = callbackFlow {
|
||||
val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
if (binder != null) {
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST") val casted = binder as? T
|
||||
if (casted != null) {
|
||||
trySend(Pair(casted, ""))
|
||||
} else {
|
||||
trySend(Pair(null, "Binder is not of type ${T::class.java}"))
|
||||
}
|
||||
} catch (e: RemoteException) {
|
||||
trySend(Pair(null, "Failed to link to death: ${e.message}"))
|
||||
}
|
||||
} else {
|
||||
trySend(Pair(null, "Binder empty"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
trySend(Pair(null, "Service disconnected"))
|
||||
}
|
||||
}
|
||||
|
||||
val success = withContext(Dispatchers.Main) {
|
||||
bindService(intent, connection, flags)
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
throw IllegalStateException("bindService() failed, will retry")
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
unbindService(connection)
|
||||
}
|
||||
}
|
||||
}.retryWhen { cause, attempt ->
|
||||
if (attempt < maxRetries && cause is Exception) {
|
||||
delay(retryDelayMillis)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val Long.formatBytes: String
|
||||
get() {
|
||||
val units = arrayOf("B", "KB", "MB", "GB", "TB")
|
||||
var size = this.toDouble()
|
||||
var unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.size - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
return if (unitIndex == 0) {
|
||||
"${size.toLong()}${units[unitIndex]}"
|
||||
} else {
|
||||
"%.1f${units[unitIndex]}".format(size)
|
||||
}
|
||||
}
|
||||
|
||||
fun String.chunkedForAidl(charset: Charset = Charsets.UTF_8): List<ByteArray> {
|
||||
val allBytes = toByteArray(charset)
|
||||
val total = allBytes.size
|
||||
|
||||
val maxBytes = when {
|
||||
total <= 100 * 1024 -> total
|
||||
total <= 1024 * 1024 -> 64 * 1024
|
||||
total <= 10 * 1024 * 1024 -> 128 * 1024
|
||||
else -> 256 * 1024
|
||||
}
|
||||
|
||||
val result = mutableListOf<ByteArray>()
|
||||
var index = 0
|
||||
while (index < total) {
|
||||
val end = minOf(index + maxBytes, total)
|
||||
result.add(allBytes.copyOfRange(index, end))
|
||||
index = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
fun <T : List<ByteArray>> T.formatString(charset: Charset = Charsets.UTF_8): String {
|
||||
val totalSize = this.sumOf { it.size }
|
||||
val combined = ByteArray(totalSize)
|
||||
var offset = 0
|
||||
forEach { byteArray ->
|
||||
byteArray.copyInto(combined, offset)
|
||||
offset += byteArray.size
|
||||
}
|
||||
return String(combined, charset)
|
||||
}
|
||||
@@ -1,34 +1,53 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
object GlobalState : CoroutineScope by CoroutineScope(Dispatchers.Default) {
|
||||
|
||||
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
|
||||
get() = application.packageName
|
||||
|
||||
val RECEIVE_BROADCASTS_PERMISSIONS: String
|
||||
get() = "${packageName}.permission.RECEIVE_BROADCASTS"
|
||||
|
||||
|
||||
private lateinit var _application: Application
|
||||
val receiveBroadcastPermission: String
|
||||
get() = "$packageName.permission.RECEIVE_BROADCASTS"
|
||||
|
||||
val application: Application
|
||||
get() = _application
|
||||
get() = checkNotNull(appInstance) { "GlobalState is not initialized" }
|
||||
|
||||
|
||||
fun log(text: String) {
|
||||
Log.d("[FlClash]", text)
|
||||
}
|
||||
@Volatile
|
||||
private var appInstance: Application? = null
|
||||
|
||||
fun init(application: Application) {
|
||||
_application = application
|
||||
appInstance = application
|
||||
}
|
||||
}
|
||||
|
||||
fun log(text: String) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
/**
|
||||
* Only the newest request is ever pending: [replace] settles the request it
|
||||
* supersedes rather than dropping it, so no caller is left waiting forever. The
|
||||
* slot is cleared before the callback runs, so a callback that starts another
|
||||
* request cannot resolve itself twice.
|
||||
*
|
||||
* The two ends of such a hop live on different threads — the request is started
|
||||
* from whatever coroutine wants the permission, the answer arrives on the main
|
||||
* thread through `onActivityResult` — so every read and write of the slot is a
|
||||
* single atomic swap. Without that, a resolve that has already read the slot can
|
||||
* clear a callback a concurrent replace installed after it, and the request that
|
||||
* callback belonged to never completes: the VPN consent answer is lost and the
|
||||
* lock the start path holds is never released.
|
||||
*
|
||||
* Callbacks run outside the lock. They resume coroutines and can start the next
|
||||
* request, and holding the monitor across that would make the slot's lock part
|
||||
* of every caller's lock order.
|
||||
*/
|
||||
class PendingCallback<T> {
|
||||
private val lock = Any()
|
||||
private var callback: ((T) -> Unit)? = null
|
||||
|
||||
val isPending: Boolean
|
||||
get() = synchronized(lock) { callback != null }
|
||||
|
||||
fun replace(next: (T) -> Unit, supersededValue: T) {
|
||||
val superseded = synchronized(lock) {
|
||||
val current = callback
|
||||
callback = next
|
||||
current
|
||||
}
|
||||
superseded?.invoke(supersededValue)
|
||||
}
|
||||
|
||||
fun resolve(value: T) {
|
||||
val current = synchronized(lock) {
|
||||
val current = callback ?: return
|
||||
callback = null
|
||||
current
|
||||
}
|
||||
current(value)
|
||||
}
|
||||
|
||||
fun cancel(target: (T) -> Unit) {
|
||||
synchronized(lock) {
|
||||
if (callback === target) {
|
||||
callback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Every request mints a fresh [Token]. Work that was started for an older token is obsolete once a
|
||||
* newer request arrives, so callers check [isCurrent] before applying a result and roll back with
|
||||
* [resetToStopped], which only wins while the token is still the latest one.
|
||||
*/
|
||||
class RunIntentArbiter(initialRunning: Boolean = false) {
|
||||
class Token internal constructor(
|
||||
val running: Boolean,
|
||||
)
|
||||
|
||||
private val latest = AtomicReference(Token(initialRunning))
|
||||
|
||||
val isRunningRequested: Boolean
|
||||
get() = latest.get().running
|
||||
|
||||
fun current(): Token = latest.get()
|
||||
|
||||
fun request(running: Boolean): Token = Token(running).also(latest::set)
|
||||
|
||||
fun isCurrent(token: Token): Boolean = latest.get() === token
|
||||
|
||||
fun resetToStopped(token: Token): Boolean =
|
||||
latest.compareAndSet(token, Token(running = false))
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class ServiceDelegate<T>(
|
||||
private val intent: Intent,
|
||||
private val onServiceDisconnected: ((String) -> Unit)? = null,
|
||||
private val interfaceCreator: (IBinder) -> T,
|
||||
) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
|
||||
private val _bindingState = AtomicBoolean(false)
|
||||
|
||||
private var _serviceState = MutableStateFlow<Pair<T?, String>?>(null)
|
||||
|
||||
val serviceState: StateFlow<Pair<T?, String>?> = _serviceState
|
||||
private var job: Job? = null
|
||||
|
||||
private fun handleBind(data: Pair<IBinder?, String>) {
|
||||
data.first?.let {
|
||||
_serviceState.value = Pair(interfaceCreator(it), data.second)
|
||||
} ?: run {
|
||||
_serviceState.value = Pair(null, data.second)
|
||||
unbind()
|
||||
onServiceDisconnected?.invoke(data.second)
|
||||
_bindingState.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun bind() {
|
||||
if (_bindingState.compareAndSet(false, true)) {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_serviceState.value = null
|
||||
job = launch {
|
||||
GlobalState.application.bindServiceFlow<IBinder>(intent).collect { handleBind(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <R> useService(
|
||||
timeoutMillis: Long = 5000, crossinline block: (T) -> R
|
||||
): Result<R> {
|
||||
return runCatching {
|
||||
withTimeout(timeoutMillis) {
|
||||
val state = serviceState.filterNotNull().first()
|
||||
state.first?.let {
|
||||
block(it)
|
||||
} ?: throw Exception(state.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
if (_bindingState.compareAndSet(true, false)) {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_serviceState.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
|
||||
fun tickerFlow(delayMillis: Long, initialDelayMillis: Long = delayMillis): Flow<Unit> = flow {
|
||||
delay(initialDelayMillis)
|
||||
while (true) {
|
||||
emit(Unit)
|
||||
delay(delayMillis)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="FlClash">FlClash</string>
|
||||
<string name="app_name">FlClash</string>
|
||||
<string name="service_channel_name">FlClash Service</string>
|
||||
</resources>
|
||||
|
||||
+238
-44
@@ -1,8 +1,13 @@
|
||||
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")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -12,35 +17,13 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
}
|
||||
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
jniLibs.srcDirs("src/main/jniLibs")
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -49,33 +32,244 @@ 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()
|
||||
|
||||
dependencies {
|
||||
implementation(libs.annotation.jvm)
|
||||
}
|
||||
val isWindows = System.getProperty("os.name").startsWith("Windows")
|
||||
|
||||
val copyNativeLibs by tasks.register<Copy>("copyNativeLibs") {
|
||||
doFirst {
|
||||
delete("src/main/jniLibs")
|
||||
// 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")
|
||||
}
|
||||
from("../../libclash/android")
|
||||
into("src/main/jniLibs")
|
||||
|
||||
doLast {
|
||||
val includesDir = file("src/main/jniLibs/includes")
|
||||
val targetDir = file("src/main/cpp/includes")
|
||||
if (includesDir.exists()) {
|
||||
copy {
|
||||
from(includesDir)
|
||||
into(targetDir)
|
||||
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")
|
||||
}
|
||||
}
|
||||
delete(includesDir)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks.named("preBuild") {
|
||||
dependsOn(copyNativeLibs)
|
||||
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 }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-keep class go.** { *; }
|
||||
-keep class com.follow.clash.core.mobile.** { *; }
|
||||
@@ -1,51 +0,0 @@
|
||||
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)
|
||||
|
||||
add_compile_options(-flto)
|
||||
|
||||
add_compile_options(-g0)
|
||||
|
||||
add_compile_options(-ffunction-sections -fdata-sections)
|
||||
|
||||
add_compile_options(-fno-exceptions -fno-rtti)
|
||||
|
||||
add_link_options(
|
||||
-flto
|
||||
-Wl,--gc-sections
|
||||
-Wl,--strip-all
|
||||
-Wl,--exclude-libs=ALL
|
||||
)
|
||||
|
||||
add_compile_options(-fvisibility=hidden -fvisibility-inlines-hidden)
|
||||
endif ()
|
||||
|
||||
set(LIB_CLASH_PATH "${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libclash.so")
|
||||
|
||||
message("LIB_CLASH_PATH ${LIB_CLASH_PATH}")
|
||||
if (EXISTS ${LIB_CLASH_PATH})
|
||||
message("Found libclash.so for ABI ${ANDROID_ABI}")
|
||||
add_compile_definitions(LIBCLASH)
|
||||
include_directories(${CMAKE_SOURCE_DIR}/../cpp/includes/${ANDROID_ABI})
|
||||
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 libclash.so for ABI ${ANDROID_ABI}")
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
jni_helper.cpp
|
||||
core.cpp)
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME})
|
||||
endif ()
|
||||
@@ -1,190 +0,0 @@
|
||||
#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_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
|
||||
const auto interface = new_global(cb);
|
||||
invokeAction(interface, get_string(data));
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_setMessageCallback(JNIEnv *env, jobject thiz, jobject cb) {
|
||||
const auto interface = new_global(cb);
|
||||
setMessageCallback(interface);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
return new_string(getTraffic(only_statistics_proxy));
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
return new_string(getTotalTraffic(only_statistics_proxy));
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
|
||||
suspend(suspended);
|
||||
}
|
||||
|
||||
|
||||
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 packageName = reinterpret_cast<jstring>(env->CallObjectMethod(
|
||||
static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_resolve_process,
|
||||
protocol,
|
||||
new_string(source),
|
||||
new_string(target),
|
||||
uid));
|
||||
return get_string(packageName);
|
||||
}
|
||||
|
||||
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
|
||||
ATTACH_JNI();
|
||||
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
|
||||
m_invoke_interface_result,
|
||||
new_string(data));
|
||||
}
|
||||
|
||||
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_invokeAction(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_setMessageCallback(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) {
|
||||
}
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
|
||||
}
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user