Compare commits
60 Commits
dev
..
v0.8.16-debug
| Author | SHA1 | Date | |
|---|---|---|---|
| d3991a9360 | |||
| 7ea226ef4a | |||
| 9bf50e0f3b | |||
| 10203b6c87 | |||
| 86aaf7ded1 | |||
| bc337c3999 | |||
| 38b1ce75d8 | |||
| a3d75d276c | |||
| 3c46cd792c | |||
| 42944b425f | |||
| 4c72877e58 | |||
| 81a19631e1 | |||
| 7f6a74b777 | |||
| eb9c3232ec | |||
| e8c9c619bf | |||
| 17cd0cf1ed | |||
| 925c331498 | |||
| 0512d692f2 | |||
| 844ce8ffb0 | |||
| d2588596df | |||
| 0e999e3deb | |||
| 8109bbf46c | |||
| 32ea45e5ea | |||
| ecce17ad75 | |||
| 96738090cf | |||
| 4a8ea37142 | |||
| a5fdb90da5 | |||
| f9722cc761 | |||
| f01fb2ed1d | |||
| 74f4481071 | |||
| 9018f512ae | |||
| 265fc4a701 | |||
| 755974fc9e | |||
| ba8eab4fc9 | |||
| f5cb46710f | |||
| 6483e80416 | |||
| 535e6dc3a5 | |||
| ad86c20cfb | |||
| 665330e17a | |||
| 0eb001e717 | |||
| a563991d74 | |||
| 3e2a30008c | |||
| ff68d573d6 | |||
| 3223fca7ba | |||
| 006f9127fc | |||
| a2709e155c | |||
| 684fa7b58e | |||
| 03b4da54b5 | |||
| a904b55d11 | |||
| d711935e2e | |||
| 98b1496eff | |||
| 442c32b6eb | |||
| 949a2aaac3 | |||
| c77463f337 | |||
| 00377d6070 | |||
| f393b4b3e9 | |||
| 75e6cfde15 | |||
| 7bfe5617d9 | |||
| 97cc96c243 | |||
| 1821ee2f61 |
@@ -1,32 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# Flutter Upgrade Checklist
|
|
||||||
|
|
||||||
Read this before changing `FLUTTER_VERSION` in `.github/workflows/build.yaml` or `.fvmrc`. Each entry is a
|
|
||||||
workaround tied to a Flutter bug: what it does, where it lives, and what has to be true before it can go. Remove an
|
|
||||||
entry together with its workaround; add one whenever a new workaround is pinned to the SDK.
|
|
||||||
|
|
||||||
## Stable cadence
|
|
||||||
|
|
||||||
Flutter ships a stable release about once a quarter, three minor versions apart (3.41 in February, 3.44 in May, 3.47
|
|
||||||
in August 2026). The two minors in between (3.48, 3.49) are beta only, so a fix that lands on `main` reaches stable
|
|
||||||
in the next multiple, 3.50, expected around mid-November 2026. Hotfix releases (3.47.x) carry only cherry-picks
|
|
||||||
listed in the SDK's `CHANGELOG.md`.
|
|
||||||
|
|
||||||
## Impeller disabled on Intel macOS release builds
|
|
||||||
|
|
||||||
- **Bug:** [flutter/flutter#191538](https://github.com/flutter/flutter/issues/191538). From 3.47, when Impeller
|
|
||||||
became the default desktop renderer, Intel (x86_64) Macs flicker across the whole window while scrolling or
|
|
||||||
animating, and text can render garbled. Apple Silicon is not affected.
|
|
||||||
- **Fix upstream:** landed on `main` before 2026-08-25 and verified on `3.48.0-1.0.pre-404`; it is not in any
|
|
||||||
3.47.x hotfix (checked through 3.47.6).
|
|
||||||
- **Workaround:** the release job for `macos-15-intel` (`arch: amd64`) runs
|
|
||||||
`plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist` before `dart setup.dart`, so only the Intel
|
|
||||||
DMG falls back to Skia. The checked-in `Info.plist` does not set the key, so local builds and the arm64 DMG keep
|
|
||||||
Impeller. The macOS embedder reads the key at launch in `FlutterDartProject.mm` (`enableImpeller`), release
|
|
||||||
builds included.
|
|
||||||
- **Side effect:** Skia on Intel renders blur, shadows and text slightly differently; judge Intel-only visual reports
|
|
||||||
against Skia, not Impeller.
|
|
||||||
- **Remove when:** `FLUTTER_VERSION` moves to a stable that contains the fix (3.50 or later, or a 3.47.x whose
|
|
||||||
changelog lists #191538). Before dropping the step, build the Intel DMG without it and scroll the proxies and
|
|
||||||
dashboard pages on an Intel Mac with its integrated GPU driving the built-in display; an external display can
|
|
||||||
switch to the discrete GPU and hide the flicker.
|
|
||||||
|
|
||||||
## Wrong-size back buffer on macOS
|
|
||||||
|
|
||||||
- **Bug:** [flutter/flutter#185394](https://github.com/flutter/flutter/issues/185394). `removeSurfaceForSize:` in
|
|
||||||
the macOS engine's `FlutterSurfaceManager.mm` hands out the youngest idle cached surface without checking its size.
|
|
||||||
When the window flips between sizes while the window server still holds the previous surface, which dragging a
|
|
||||||
window edge back and forth does, a request for one size gets a surface of another. Impeller's Metal texture then
|
|
||||||
disagrees with its descriptor, the render target drops it, and `Canvas::SetupRenderPass` dereferences a null
|
|
||||||
texture on the raster thread. Debug builds log `The texture and its descriptor disagree about its size` and
|
|
||||||
`Store action needs resolve but no valid resolve texture specified` first; release builds just crash.
|
|
||||||
- **Fix upstream:** [flutter/flutter#192522](https://github.com/flutter/flutter/pull/192522), still open; 3.47.6
|
|
||||||
ships the same `FlutterSurfaceManager.mm` as 3.47.4.
|
|
||||||
- **Workaround:** `BackBufferCacheGuard` in the `window` plugin's
|
|
||||||
`macos/window/Sources/window/BackBufferCacheGuard.swift`, installed from `WindowPlugin.register`, replaces
|
|
||||||
`-[FlutterBackBufferCache removeSurfaceForSize:]` at runtime and answers nil when the cached surface is not the
|
|
||||||
requested size, so the engine allocates a new one. It does nothing if the engine no longer has that class or
|
|
||||||
selector.
|
|
||||||
- **Remove when:** `FLUTTER_VERSION` moves to a stable that contains #192522 (check the SDK `CHANGELOG.md` for a
|
|
||||||
3.47.x cherry-pick). Before dropping it, run a debug build without the guard on that SDK and drag a window edge
|
|
||||||
back and forth for a minute; neither validation line may appear.
|
|
||||||
|
|
||||||
## Windows view visibility forwarded by the runner
|
|
||||||
|
|
||||||
- **Bug:** the Windows embedder's `WindowsLifecycleManager` counts the Flutter view as a window of its own and keeps
|
|
||||||
it visible until the view is sized to zero. Hiding the top-level window never resizes the view, and the `window`
|
|
||||||
plugin keeps it sized while minimized, so the app was never told `hidden`: animations kept rendering in the tray
|
|
||||||
and `appVisibleProvider` stayed true.
|
|
||||||
- **Workaround:** `WindowPluginForwardVisibility` in the `window` plugin's
|
|
||||||
`windows/include/window/window_plugin_runner.h` hands the top-level window's `WM_SHOWWINDOW` and `WM_SIZE` to
|
|
||||||
`ProcessExternalWindowMessage` for the view's HWND, so the view shows and hides with its window.
|
|
||||||
`FlutterWindow::MessageHandler` in `windows/runner/flutter_window.cpp` calls it before `HandleTopLevelWindowProc`;
|
|
||||||
the Flutter runner template has no such call, so keep it when regenerating the runner.
|
|
||||||
- **Remove when:** the engine hides the view together with its top-level window. Check with the proxy running and
|
|
||||||
the window focused: hiding it to the tray and minimizing it must each log `AppLifecycleState.hidden`.
|
|
||||||
|
|
||||||
## macOS activation resumes a hidden window
|
|
||||||
|
|
||||||
- **Bug:** `handleWillBecomeActive` in the macOS engine's `FlutterEngine.mm` resumes when any window in
|
|
||||||
`NSApp.windows` is visible, and the tray's `NSStatusBarWindow` always is. Activating the app while its window was
|
|
||||||
minimized or hidden, such as Cmd-Tab back to a minimized window, reported `resumed`, and the next deactivation
|
|
||||||
`inactive`, so frames ran until the window was shown again.
|
|
||||||
- **Workaround:** the `window` plugin observes will-become-active and will-resign-active and, once the engine has
|
|
||||||
answered, sends `AppLifecycleState.hidden` on `flutter/lifecycle` while its window is not visible
|
|
||||||
(`applicationActivityChanged` in `WindowPlugin.swift`). A window brought back without another activation change
|
|
||||||
would then wait on an occlusion notification macOS does not always post, keeping its last frame, so `show` and a
|
|
||||||
deminiaturize hand the state back (`windowBecameVisible`).
|
|
||||||
- **Remove when:** the engine ignores status item windows on activation. Check without the workaround: minimize the
|
|
||||||
window, switch to another app, then Cmd-Tab back without restoring it; the log must stay at
|
|
||||||
`AppLifecycleState.hidden`.
|
|
||||||
|
|
||||||
## Linux minimize on Wayland reads as inactive
|
|
||||||
|
|
||||||
- **Bug:** the Linux engine reports `hidden` for `GDK_WINDOW_STATE_ICONIFIED`, but xdg-shell has no minimized state,
|
|
||||||
so GTK 3 never sets it on Wayland. A minimized window reported only its focus loss, as `inactive`, and kept
|
|
||||||
rendering at the display rate (GNOME 46).
|
|
||||||
- **Workaround:** the `window` plugin's Linux `minimize` records the time, and a focus loss within 500 ms of it sends
|
|
||||||
`AppLifecycleState.hidden`. Its `window-state-event` handler is connected after the engine's, so that message
|
|
||||||
follows the engine's `inactive`; the compositor focuses the window when it restores it, which the engine reports as
|
|
||||||
`resumed`. A minimize the compositor starts itself (Super+H, the dock) is not covered.
|
|
||||||
- **Remove when:** the Linux engine reports minimized windows on Wayland, which needs GTK 4 or xdg-shell's
|
|
||||||
`suspended` state. Check in a GNOME Wayland session without the workaround: minimizing from the caption button
|
|
||||||
must log `AppLifecycleState.hidden`.
|
|
||||||
|
|
||||||
## Liquid glass saves around the transform of a blurring layer
|
|
||||||
|
|
||||||
- **Bug:** not filed upstream. On 3.47 Impeller scales a `saveLayer`'s image filter by a transform set inside the
|
|
||||||
layer that is still in effect when the layer is restored. A host's capture shrinks each pane's slot inside the
|
|
||||||
layer that blurs it, so its blur vanished at a quarter scale and spread half again as far at 1.5.
|
|
||||||
- **Workaround:** `_LiquidGlassHostLayer._snapshot` sets each slot's transform inside a `save` of its own, and each
|
|
||||||
band's offset, restored before the layer is.
|
|
||||||
- **Remove when:** without that `save`, the frosted-pane case of `test/widgets/liquid_glass_host_test.dart` passes
|
|
||||||
under `flutter test --enable-impeller`.
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,663 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
# Setting Changes
|
|
||||||
|
|
||||||
A changed setting takes one of three paths. The path depends on who reads the setting and when it reads it, not on
|
|
||||||
which page edits it. Choose the path when you add a setting or move one.
|
|
||||||
|
|
||||||
| Path | Who reads the setting | Watched provider | Acts | Effect |
|
|
||||||
|---------|---------------------------------------------|----------------------------------------|-------------------------------|--------------------------------------------|
|
|
||||||
| Update | Core, and Go can change it in place | `updateParamsProvider` | `CoreManager`, automatically | Patches the running config, no reload |
|
|
||||||
| Apply | Core, only when it parses a config | `setupPatchProvider` | `CoreManager`, automatically | Rebuilds the profile and reloads the Core |
|
|
||||||
| Restart | Android service, when it starts | `vpnOptionsProvider` | `VpnManager` asks the user | Stop and start of the service |
|
|
||||||
|
|
||||||
Update and apply run on their own, debounced. A restart never does: rebuilding the tunnel cuts every connection
|
|
||||||
through it, so `VpnManager` only shows a notifier and the user taps its restart action. A setting that only Flutter
|
|
||||||
reads takes no path.
|
|
||||||
|
|
||||||
## Update
|
|
||||||
|
|
||||||
`CoreManager` hands each change of `updateParamsProvider` to `SetupAction.updateConfigDebounce`. `updateConfig` then sends
|
|
||||||
`PatchClashConfig.toUpdateParams` to Go's `updateConfig` in `core/config.go`. That function writes the fields into the
|
|
||||||
running config and calls the runtime setter for each one, or `updateListeners` for the inbounds. Nothing is re-parsed.
|
|
||||||
|
|
||||||
The update path carries these fields:
|
|
||||||
|
|
||||||
- From `PatchClashConfig`: `mode`, `log-level`, `mixed-port`, `allow-lan`, `ipv6`, `find-process-mode`,
|
|
||||||
`tcp-concurrent`, `unified-delay`, `external-controller`, `secret`, `geo-auto-update`, `geo-update-interval`,
|
|
||||||
`geox-url`, and the `tun` keys of `Tun.coreJson`: `enable`, `device`, `stack`, and `auto-route`, always on.
|
|
||||||
- From other settings: `NetworkProps.authentication`, and `AppSettingProps.checkCertificate`, sent as
|
|
||||||
`skip-cert-verify`.
|
|
||||||
|
|
||||||
On desktop, `updateListeners` rebuilds the TUN from the patched `tun`. Enabling TUN may need authorization first. When
|
|
||||||
`requestAdmin` gets it newly granted, `updateConfig` restarts the Core instead of patching it. On Android,
|
|
||||||
`updateListeners` skips TUN, so a `tun` update reaches the VPN only through a restart.
|
|
||||||
|
|
||||||
To move a setting onto this path:
|
|
||||||
|
|
||||||
1. Add it to Dart `UpdateParams` and `toUpdateParams`.
|
|
||||||
2. Add it to Go `UpdateParams` in `core/protocol.go` and apply it in `updateConfig`.
|
|
||||||
3. Reset it in `PatchClashConfig.setupOnly`. If you skip this, every change also reloads the profile. The
|
|
||||||
`PatchClashConfig.setupOnly` test in `test/models/config_test.dart` fails until its fixture changes the field and
|
|
||||||
`setupOnly` resets it.
|
|
||||||
4. Keep writing it in `_makeRealProfileTask`, so a full apply hands the Core the same value.
|
|
||||||
|
|
||||||
Prefer update whenever Go has a setter for the setting, because an apply reloads the whole config.
|
|
||||||
|
|
||||||
## Apply
|
|
||||||
|
|
||||||
`CoreManager` answers each change of `setupPatchProvider` with `applyProfileDebounce(silence: true)`. That call
|
|
||||||
rebuilds the profile YAML and hands it to Go's `applyConfig` through `setupConfig`. The provider holds three values:
|
|
||||||
|
|
||||||
- `PatchClashConfig.setupOnly`: the config with every field that update carries reset to its default.
|
|
||||||
- `NetworkProps.appendSystemDns`.
|
|
||||||
- `NetworkProps.bypassPrivateRoute` on desktop, where the task adds the private ranges to the TUN's final
|
|
||||||
`route-exclude-address`.
|
|
||||||
|
|
||||||
A change to anything else in `PatchClashConfig` therefore reaches it. Today that is the DNS and TUN overrides (`dns`,
|
|
||||||
`dnsOverrideKeys`, `tunOverride`, `tunOverrideKeys`), `hosts`, the extra inbound ports (`port`, `socks-port`,
|
|
||||||
`redir-port`, `tproxy-port`), the dialer options (`keep-alive-*`, `disable-keep-alive`, `interface-name`, its mode,
|
|
||||||
`routing-mark`), `geodata-loader` and `global-ua`. A new `PatchClashConfig` field written by `_makeRealProfileTask` lands here without further work. A value
|
|
||||||
the profile build reads from another settings object has to be added to `setupPatchProvider`.
|
|
||||||
|
|
||||||
An apply without `force` sends nothing when the md5 of the generated YAML has not changed. A value that only
|
|
||||||
`SetupParams` carries never changes the YAML, so this path does not deliver it. `testUrl` is that case: Go's default
|
|
||||||
health-check URL follows it only when some other change or a `force` apply reaches the Core.
|
|
||||||
|
|
||||||
Profile content does not go through `setupPatchProvider`. `setupStateProvider` reads rules, groups and scripts from the
|
|
||||||
database once instead of watching them. Content is applied explicitly by whatever changes it:
|
|
||||||
|
|
||||||
- Profile editors call `autoApplyProfile` when they close.
|
|
||||||
- Subscription updates, scripts and app-level providers call `applyProfileDebounce`.
|
|
||||||
- A profile switch runs `fullSetup`.
|
|
||||||
- A start applies again with `force` unless the last apply saw the same inputs (`_inputsApplied`), the `testUrl` and
|
|
||||||
`skipCertVerify` that only `SetupParams` carries included.
|
|
||||||
|
|
||||||
## Restart
|
|
||||||
|
|
||||||
`VpnService.handleStart` builds the tunnel once from `VpnOptions` and never reads them again.
|
|
||||||
`ServiceController.start` uses `enable` to choose between `VpnService` and `ProxyService`.
|
|
||||||
|
|
||||||
`vpnOptionsProvider` derives the options the service would start with now. `runningVpnOptionsProvider` records what
|
|
||||||
the last listener start handed it. The route list is subtracted in an isolate by `vpnRouteAddressProvider`, which
|
|
||||||
reruns only when the route lists or `bypassPrivateRoute` change, so `vpnOptionsProvider` is async: a start awaits it
|
|
||||||
before recording, the Android Core awaits it before its first `syncState`, and the shared state keeps the last options
|
|
||||||
while new routes compute rather than handing the service an empty list. `SetupAction` owns every start and stop: it
|
|
||||||
sets the record on a start and clears it on a stop, on-demand SSID suspension included.
|
|
||||||
|
|
||||||
`VpnManager` shows the notifier whenever a change leaves `VpnOptions.effective` different from the recorded options.
|
|
||||||
Repeated changes refresh the same notifier instead of stacking new ones. Changing a setting back to the running value
|
|
||||||
asks for nothing. The tap runs `SetupAction.restartVpn`: a stop, then a start, and only while a run is up.
|
|
||||||
|
|
||||||
| `VpnOptions` field | Taken from | The service reads it |
|
|
||||||
|------------------------|---------------------------------------------------------------|----------------------------------------|
|
|
||||||
| `enable` | `VpnProps.enable`, off in safe mode | always |
|
|
||||||
| `stack` | `Tun.stack` | with the VPN |
|
|
||||||
| `ipv6`, `dnsHijacking` | `VpnProps` | with the VPN |
|
|
||||||
| `allowBypass` | `VpnProps` | with the VPN |
|
|
||||||
| `accessControlProps` | `VpnProps.accessControlProps` | `enable`, `mode` and that mode's list |
|
|
||||||
| `systemProxy` | `VpnProps.systemProxy`, off under local auth or safe mode | with the VPN |
|
|
||||||
| `port` | `PatchClashConfig.mixedPort` | with `systemProxy` |
|
|
||||||
| `bypassDomain` | `NetworkProps.bypassDomain` | with `systemProxy` |
|
|
||||||
| `routeAddress` | `Tun.routeAddress` and `bypassPrivateRoute` | with the VPN |
|
|
||||||
| `mtu` | `Tun` | with the VPN |
|
|
||||||
| `congestionController` | `Tun` | with the VPN |
|
|
||||||
|
|
||||||
The service never reads the access list's sort and filter fields. `effective` resets them, along with every field
|
|
||||||
the table does not mark as read, so changing them does not ask for a restart.
|
|
||||||
|
|
||||||
To add a VPN option:
|
|
||||||
|
|
||||||
1. Add it to Dart and Kotlin `VpnOptions` and to `vpnOptionsProvider`.
|
|
||||||
2. If the service reads it only under a condition, put that condition in `VpnOptions.effective`, which mirrors
|
|
||||||
`ServiceController.start` and `VpnService.handleStart`.
|
|
||||||
|
|
||||||
The notifier stays off while nothing runs, during on-demand suspension, in safe mode, and on desktop, where
|
|
||||||
`VpnManager` is not mounted. When Flutter reattaches to a service that is already running, it records the current
|
|
||||||
options, because the service does not report the options it was started with.
|
|
||||||
|
|
||||||
## One Setting, Several Paths
|
|
||||||
|
|
||||||
Each path watches its own provider, so a single setting can take more than one of them:
|
|
||||||
|
|
||||||
- `mixedPort`: update moves the Core's listener; under the VPN system proxy, the restart notice follows.
|
|
||||||
- TUN switch and stack: update rebuilds the desktop TUN. On Android the update reaches no TUN, and the restart notice
|
|
||||||
carries the stack.
|
|
||||||
- MTU, congestion control and routes: Android's rows take the restart path; on desktop they are TUN overrides, applied
|
|
||||||
with the profile.
|
|
||||||
- `bypassPrivateRoute`: apply on desktop, restart on Android.
|
|
||||||
- Local authentication: update applies it to the inbounds. It also switches the VPN system proxy, which needs a
|
|
||||||
restart.
|
|
||||||
|
|
||||||
## Choosing The Path
|
|
||||||
|
|
||||||
For a new or moved setting, answer each question. More than one answer can be yes.
|
|
||||||
|
|
||||||
1. Does the Android service read it when it starts? Then it belongs in `VpnOptions` and takes the restart path.
|
|
||||||
2. Does the Core read it, and can Go change it in place? Then it belongs in `UpdateParams` and takes the update path.
|
|
||||||
3. Does the Core read it only when it parses a config? Then write it into the generated config, and it takes the
|
|
||||||
apply path.
|
|
||||||
4. Does only Flutter read it? Then it takes no path.
|
|
||||||
|
|
||||||
Tests that hold these rules:
|
|
||||||
|
|
||||||
- `test/models/config_test.dart` (`PatchClashConfig.setupOnly`).
|
|
||||||
- `test/models/core_test.dart` (`VpnOptions.effective`).
|
|
||||||
- `test/providers/state_derived_test.dart` (the setup patch).
|
|
||||||
- `test/manager/core_manager_test.dart` (update against apply).
|
|
||||||
- `test/manager/vpn_manager_test.dart` (the restart notice).
|
|
||||||
- `test/providers/setup_action_test.dart` (running options, suspension, `restartVpn`).
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
---
|
|
||||||
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`.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
---
|
|
||||||
name: pre-commit-quality-review
|
|
||||||
description: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Pre-commit Quality Review
|
|
||||||
|
|
||||||
Check that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,
|
|
||||||
so later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill
|
|
||||||
grants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is
|
|
||||||
not consent.
|
|
||||||
|
|
||||||
## Triage
|
|
||||||
|
|
||||||
Most commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a
|
|
||||||
repository-wide scan.
|
|
||||||
|
|
||||||
- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the
|
|
||||||
normal commit flow without extra confirmation. Say the review was skipped, never that it passed.
|
|
||||||
- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions
|
|
||||||
change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws
|
|
||||||
responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.
|
|
||||||
- An explicit structural review request bypasses triage.
|
|
||||||
|
|
||||||
The snapshot under review is the intended commit compared with its baseline:
|
|
||||||
|
|
||||||
| Operation | Baseline |
|
|
||||||
|---|---|
|
|
||||||
| Regular commit | `HEAD` |
|
|
||||||
| Amend | Parent of the commit being replaced |
|
|
||||||
| Squash | Version preceding the agreed range |
|
|
||||||
| Initial commit | Empty tree |
|
|
||||||
|
|
||||||
Ask when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of
|
|
||||||
partially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial
|
|
||||||
staging.
|
|
||||||
|
|
||||||
## Review the Final Structure
|
|
||||||
|
|
||||||
Scope the review to the responsibility boundaries this change touched. Read the complete final implementation of the
|
|
||||||
affected units and compare before and after, not only the last patch. Report issues this change introduced or
|
|
||||||
worsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a
|
|
||||||
concrete concern needs, and disclose missing essential context rather than claiming a complete pass.
|
|
||||||
|
|
||||||
Focus on:
|
|
||||||
|
|
||||||
- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled
|
|
||||||
with implementation detail; responsibilities in layers that should not own them.
|
|
||||||
- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate
|
|
||||||
nothing; similar names or interfaces hiding different responsibilities.
|
|
||||||
- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually
|
|
||||||
dependent helpers; names and decomposition that do not reduce reading effort.
|
|
||||||
- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,
|
|
||||||
scattered ownership of error handling, cleanup, or state updates.
|
|
||||||
- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite
|
|
||||||
invalidated; coupling that makes existing test boundaries hard to use.
|
|
||||||
|
|
||||||
Not findings: length, a missing design pattern, naming taste, or anything without code evidence and a real
|
|
||||||
maintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,
|
|
||||||
or demand abstractions for speculative needs.
|
|
||||||
|
|
||||||
Business logic, UI behavior, performance, security, platform compatibility, and full functional review are out of
|
|
||||||
scope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or
|
|
||||||
widening this review.
|
|
||||||
|
|
||||||
## Findings and the User's Decision
|
|
||||||
|
|
||||||
A finding qualifies only when all three hold:
|
|
||||||
|
|
||||||
- Before-and-after code and the necessary call relationships show this change introduced or worsened it.
|
|
||||||
- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one
|
|
||||||
responsibility. Clarity or elegance alone is not enough.
|
|
||||||
- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.
|
|
||||||
|
|
||||||
With no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the
|
|
||||||
existing commit authorization. Do not ask for confirmation because a review happened, and do not collect optional
|
|
||||||
improvements into a suggestion list.
|
|
||||||
|
|
||||||
With qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,
|
|
||||||
and give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the
|
|
||||||
smallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to
|
|
||||||
optimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the
|
|
||||||
user recognizes their configured workflow, for example: "The structural issues above support consolidating the
|
|
||||||
duplicate state-update paths. Should I make that change before committing?"
|
|
||||||
|
|
||||||
Existing explicit authorization for these findings is enough to act without asking again. The user may accept all,
|
|
||||||
some, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were
|
|
||||||
restaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.
|
|
||||||
|
|
||||||
## Resolve and Finish
|
|
||||||
|
|
||||||
Once authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task
|
|
||||||
scope; explain separately if either must change. Verify only the accepted findings and the direct effects of the
|
|
||||||
adjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold
|
|
||||||
and exceeds the existing authorization.
|
|
||||||
|
|
||||||
Before committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and
|
|
||||||
review only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,
|
|
||||||
`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse
|
|
||||||
results that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot
|
|
||||||
as verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the
|
|
||||||
commit.
|
|
||||||
|
|
||||||
Report the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.
|
|
||||||
Claim the commit is done only after it exists.
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
# Surfaces
|
|
||||||
|
|
||||||
FlClash draws structure with fills and keeps color for state. A surface stands out from what it sits on by a step in
|
|
||||||
lightness, never by a border. Color appears where something is selected, on, failing, or the one action a screen is
|
|
||||||
for. The dashboard set this direction; every other screen follows it, and Material components keep their behavior
|
|
||||||
but give up their tonal container fills.
|
|
||||||
|
|
||||||
## Levels
|
|
||||||
|
|
||||||
`SurfaceLevel` in `lib/ui/surface.dart` names four depths. Each surface takes the level one deeper than the one
|
|
||||||
it sits on, which `SurfaceScope` passes down the tree.
|
|
||||||
|
|
||||||
| Level | Light | Dark | Holds |
|
|
||||||
|---------|---------------------------|---------------------------|---------------------------------------------------------|
|
|
||||||
| `page` | `surfaceContainer` | `surface` | scaffold body, sheets |
|
|
||||||
| `card` | `surfaceBright` | `surfaceContainer` | cards, grouped runs of rows, light-mode floats |
|
|
||||||
| `block` | `surfaceContainer` | `surfaceContainerHigh` | unselected segments, tiles and wells in a card |
|
|
||||||
| `inset` | `surfaceContainerHighest` | `surfaceContainerHighest` | small marks inside a block: glyph wells, badges, tracks |
|
|
||||||
|
|
||||||
Light mode alternates gray and paper: a near-white card on a gray page, a gray block in the card. The card is tone 98,
|
|
||||||
not pure white, and the page tone 94: pure white beside a tinted gray made the white glare and the gray look soiled. Dark mode only
|
|
||||||
lightens, because a darker step inside a lighter surface reads as a hole rather than a layer. `inset` is the floor;
|
|
||||||
nothing nests below it, so a design that needs a fifth level needs fewer containers instead.
|
|
||||||
|
|
||||||
- A widget never picks its level at the call site. `CommonCard` and the other shared surfaces read
|
|
||||||
`context.raisedSurfaceLevel` and wrap their child in `SurfaceScope`, so the same row is white on a page and gray
|
|
||||||
inside a card. A branch on `isInBottomSheet` or on where a widget is placed to choose its fill is the bug this
|
|
||||||
replaces.
|
|
||||||
- Sheets are `page` level, like a page, so a section looks the same in a sheet and in a page. In dark mode a sheet
|
|
||||||
presents a level up, as iOS presents a sheet on its elevated backgrounds: `ColorScheme.elevated` lifts every
|
|
||||||
surface role by the step from `page` to `card`, so the sheet's page takes the tone of the cards it covers and every
|
|
||||||
level inside it follows. Under pure black the sheet takes the plain dark scheme instead, which is the same step up
|
|
||||||
and keeps the seed's tint that a lift from black would lose. `ElevatedColorScheme` carries the result and the sheet
|
|
||||||
routes apply it through `ElevatedSurfaceTheme`; a sheet opened from a sheet keeps the same lift. A scrim barely
|
|
||||||
darkens a dark page, so a dark sheet at the page's own tone read as more of the page rather than a layer over it,
|
|
||||||
a covered sheet's peek vanished, and on black the edge and corners went with it. Light mode keeps its levels,
|
|
||||||
because the scrim already sets a light sheet apart.
|
|
||||||
- What floats over content, dialogs, menus, popups, the mobile dock, toasts, takes `SurfaceLevel.floatingOf`: `card`
|
|
||||||
in light, `block` in dark. A shadow does not show on a dark page, so a float has to come out lighter than the cards
|
|
||||||
it covers, and its content scopes to that level so a block inside still steps away from it.
|
|
||||||
- Shadows mark what floats, plus the dashboard cards. Cards in a scrolling list have none; a grid of proxies with a
|
|
||||||
blurred shadow each costs raster time for no extra layering. A render object that paints a shadow itself draws it
|
|
||||||
with `drawRSuperellipse`, as `ShapeDecoration` does: Impeller blurs a superellipse in one analytic pass, but a path
|
|
||||||
through an offscreen Gaussian blur on every frame, which made the shadows the largest raster cost of an opening menu.
|
|
||||||
|
|
||||||
## The Seeded Scheme
|
|
||||||
|
|
||||||
`seededColorScheme` in `lib/ui/color.dart` builds every app scheme: Material's scheme for the seed and the
|
|
||||||
`ColorStyle` picked in the theme settings, with three groups of roles replaced. Everything else stays Material,
|
|
||||||
including `toPureBlack` applied after it.
|
|
||||||
|
|
||||||
The four styles each change something a reader sees at once. Material's nine variants mostly moved secondary and
|
|
||||||
tertiary, which the app barely shows, so most of them looked alike once surfaces stayed gray.
|
|
||||||
|
|
||||||
| Style | Material variant | Primary | Surfaces |
|
|
||||||
|--------------|------------------|-------------------|---------------------------------|
|
|
||||||
| `vivid` | content | the seed | near gray |
|
|
||||||
| `tinted` | content | the seed | the seed's hue, up to chroma 10 |
|
|
||||||
| `soft` | tonal spot | Material's, muted | near gray |
|
|
||||||
| `monochrome` | monochrome | black or white | gray |
|
|
||||||
|
|
||||||
|
|
||||||
- **Surfaces stay near gray.** Each `surface*` role keeps Material's tone but its chroma is capped at 3, the light
|
|
||||||
`surface` and `surfaceBright` at 1.5, and `outlineVariant` at 4. Material tints neutrals by the seed (content
|
|
||||||
divides the seed's chroma by 8), so a yellow, green, or rose seed tinted the whole page. `tinted` is that wash
|
|
||||||
chosen on purpose and made even: every surface takes the seed's hue at a sixth of its chroma, capped at 10, so a
|
|
||||||
gray seed stays gray and any vivid one tints about as much.
|
|
||||||
- **`primaryContainer` comes from the primary palette.** It is primary's hue at tone 90 in light and 30 in dark,
|
|
||||||
with `onPrimaryContainer` at 30 and 90, and the pair's chroma is capped at 48. Content and fidelity otherwise put it
|
|
||||||
at the seed's own tone: a yellow seed filled it with pure yellow, a red seed with something that read as an error,
|
|
||||||
and dark mode with a light block under light text. The cap matters for light, chromatic hues: yellow at tone 90 is
|
|
||||||
still close to full strength.
|
|
||||||
- **`vivid` and `tinted` keep the seed as `primary`**, as an Apple accent color does: the color picked in the theme
|
|
||||||
settings is the color of every check, switch, and filled button. Light holds the seed's tone between 35 and 60, so
|
|
||||||
white text on it keeps about 3:1 and a near-black seed still reads as a color; dark lifts it 3 tones, as iOS lifts
|
|
||||||
its system colors, within 50 to 75, and puts dark text on it above 60. Material sets primary at tone 40 and 80 for
|
|
||||||
any seed, which turned iOS blue into navy in light and a pastel in dark. `soft` and `monochrome` keep Material's
|
|
||||||
primary, since moving away from the seed is what they are for.
|
|
||||||
|
|
||||||
## State
|
|
||||||
|
|
||||||
Color marks the one item that differs, never the structure around it.
|
|
||||||
|
|
||||||
- **Selected**, one of several rows, cards, or tiles: the item keeps its level's fill and a primary mark says which,
|
|
||||||
as iOS checks the option picked from a list. A row in a grouped run takes `SelectionCheck` at its edge; a card that
|
|
||||||
stands on its own, a profile or a proxy, and a tile take the filled `SelectionBadge`, which holds its own beside a
|
|
||||||
40 dp icon well where the bare check read as small and thin (`lib/ui/selection_mark.dart`). `CommonCard.isSelected` only reports the state to
|
|
||||||
accessibility. Fills were tried and rejected: a wash of primary landed on a gray that did not read as a choice, and
|
|
||||||
`primaryContainer` read as a warning under rose seeds in light and as a heavy slab in dark, muddied the translucent
|
|
||||||
marks on it, and cut a grouped run in two.
|
|
||||||
- **Multi-select** in edit mode: the circle checkbox is the mark; the row stays at its level.
|
|
||||||
- **Proxy groups** have two states in URLTest and Fallback: the check badge marks the proxy traffic goes through, in
|
|
||||||
every group type, and a pin badge (`SelectionBadge.preferred`) marks the preferred proxy (mihomo's `fixed`): filled
|
|
||||||
like the check while the group uses it, tonal while the group has fallen back to another proxy, which carries the
|
|
||||||
check.
|
|
||||||
- **Chosen segment** in a connected button group, a mode switch, or a run of compact choice chips
|
|
||||||
(`CommonCard.fillsSelection`): solid `primary` with `onPrimary`, as the dashboard outbound mode does. Only one
|
|
||||||
segment per group.
|
|
||||||
- **Error** on a row or card: `errorContainer` with `onErrorContainer`, no border.
|
|
||||||
- **Status** values (delay, availability, success, warning): the color goes on text, a glyph, or a small chip, never
|
|
||||||
on a whole card. `success` and `warning` come from fixed green and orange palettes that ignore the seed, like
|
|
||||||
Material's `error`; harmonizing pulled orange toward red under rose seeds until warning and error matched. Text takes
|
|
||||||
tone 40 in light and 80 in dark, a chip takes `successContainer`/`warningContainer` (90/30) with the `on` role
|
|
||||||
(30/90). A delay under 600 ms is success, above it warning, a failure error: `ColorScheme.delayColor`.
|
|
||||||
- **Destructive** action in an alert: `ColorScheme.destructive`, iOS 27's systemRed in both modes, not `error`. A
|
|
||||||
menu's `danger` item still takes `error`.
|
|
||||||
- **Data series** on one chart: the first is primary, the second primary at 45% over the card
|
|
||||||
(`DashboardColors.series2`), so they differ by lightness under any seed. Tertiary is not a series color.
|
|
||||||
- **Text**: titles and values `onSurface`, labels and secondary lines `onSurfaceVariant`. `outline` is not a text
|
|
||||||
color, and a value is never tinted primary.
|
|
||||||
- **Marks** that only identify, a country badge, a group or service icon, a source avatar, sit on a neutral well with
|
|
||||||
`onSurfaceVariant`.
|
|
||||||
- `primaryContainer`, `secondaryContainer`, and `tertiaryContainer` are neither surface nor selection fills. Keep
|
|
||||||
them out of cards, rows, marks, and navigation indicators.
|
|
||||||
|
|
||||||
## Borders That Stay
|
|
||||||
|
|
||||||
- **Focus**: a 2 dp primary stroke while a surface has keyboard or remote focus. TV navigation depends on it, and a
|
|
||||||
fill change alone is too faint to follow from a couch. A card, a grouped row above all, rings only focus a key moved
|
|
||||||
there (`FocusMovedByKey`), or any focus while a remote drives the app: a tap or a click puts focus where the reader
|
|
||||||
is already looking, a field's caret already marks it, and the ring then read as a selection box around the row.
|
|
||||||
- **Inputs**: a form's field is a bare `FormRowTextField` at the end of a grouped `FormRow` whose title labels it, as
|
|
||||||
iOS and macOS set one, with no stroke or fill of its own. A field among filled controls takes their fill, one level
|
|
||||||
above its surface, through `FilledInputTheme`. An alert's fields, because they sit over its pill buttons, as
|
|
||||||
UIAlertController sets them: as wide as the buttons, in their fill, with their text in line with the title and the
|
|
||||||
label as the placeholder (`CommonDialog.hasFields`); a field under a filled block of text in an alert takes the
|
|
||||||
block's width and corner instead. The custom color's hex field, because its card holds only fills, the color
|
|
||||||
previews, where an outline was the one stroked thing. Search fields are filled pills. A sheet's docked search,
|
|
||||||
because the list scrolls under it: it floats like the dock, with the floating fill and the dock's shadow, or the
|
|
||||||
dock's glass. The editor's find bar, because its fields sit in a row of filled pill buttons: they take the buttons'
|
|
||||||
fill, one level above the bar. All of these stroke only while focused. Any other field, one standing alone on a page,
|
|
||||||
is outlined, with the floating label notched into the stroke: `ThemeData.withAppShapes` gives every input
|
|
||||||
`AppShape.input`, so call sites leave `border` unset. A lone field is never filled: here a fill means structure, so
|
|
||||||
one read as one more card or row, and in light mode the hover overlay brought it down to the page's own tone. No
|
|
||||||
field counts its characters, as none does on iOS or macOS; a length limit holds through its formatter. Material
|
|
||||||
strokes a filled field's whole outline in `onSurfaceVariant`, which ringed the pill brighter than anything else in a
|
|
||||||
dark sheet.
|
|
||||||
- **Window chrome**: the desktop sidebar's inner edge and the title bar's bottom edge draw a divider where they meet
|
|
||||||
the content. The chrome fill alone left the edge to a four-tone step, which read as a smudge rather than a boundary.
|
|
||||||
|
|
||||||
Nothing else draws an outline. Rows in a grouped run are separated by the hairline from `lib/ui/list_row_separator.dart`, which divides
|
|
||||||
one surface and does not outline it. Record lists, requests, connections, logs, and DNS queries, are not grouped: they
|
|
||||||
run to thousands of tall rows, and one card that long reads as a slab rather than a group. A stacked row of three or
|
|
||||||
four lines is already its own block, so each is a card of its own, `RecordListItem`: the raised fill at `xl`, 16 dp
|
|
||||||
from the edges and 8 dp from the next, as the profile cards sit. Every card takes the same fill, so the warning and
|
|
||||||
error tints on a label still stand out, where a fill on every other row competed with them; rows on the page split by
|
|
||||||
a full-width divider were the one structural line left on screens otherwise drawn with fills. Laid out as a table,
|
|
||||||
where a row is one line read across many columns, they drop the gap and give every other row the raised fill, inset
|
|
||||||
and rounded like the column header, so the eye keeps its row; the dashboard's table does the same.
|
|
||||||
|
|
||||||
Every divider takes its color from `ThemeData.withAppSurfaces`: `onSurface` at 14%, never `outlineVariant`. A
|
|
||||||
translucent line keeps the same contrast on the page, a card, or the chrome, in both modes; `outlineVariant` stood out
|
|
||||||
twice as much on the dark page as on the light one and carried the seed's tint.
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
- The desktop sidebar and title bar are window chrome, `ColorScheme.chromeSurface`: `surfaceContainerHighest` in
|
|
||||||
light and `surfaceContainerLow` in dark, four tones from the page either way, with a divider along the edge that
|
|
||||||
meets the content. At `page` level the sidebar merged with the content. In dark the chrome stays below `card`, so
|
|
||||||
cards still stand out from it. The pane the sidebar opens over the content takes the same fill and edge, plus a
|
|
||||||
shadow.
|
|
||||||
The selected destination keeps its neutral fill and primary bar, which already marks it with color.
|
|
||||||
- The mobile dock and the docked navigation bar are floats; their indicator is a neutral platter one level above
|
|
||||||
them, and the selected destination's content takes primary, as iOS's tab bar marks its selection.
|
|
||||||
- With Liquid Glass on (Theme settings), the dock, the buttons of a page's bar, a sheet's docked search, popup
|
|
||||||
menus, and alerts are glass from `lib/ui/liquid_glass.dart` instead of a fill, and the dock's platter takes the
|
|
||||||
secondary hue and chroma at tone 90, or at tone 4 in dark, which sinks it below the bar as iOS 27 does.
|
|
||||||
- The proxies page's group tabs are compact choice chips, the current group in the chosen segment's solid `primary`.
|
|
||||||
Once they overflow, a tonal button at their end opens every group as a popup menu, the current one checked and
|
|
||||||
scrolled into view. A profile can carry dozens of groups, so the menu stops at seven rows and half of the next.
|
|
||||||
|
|
||||||
## Where It Lives
|
|
||||||
|
|
||||||
- `lib/ui/surface.dart`: `SurfaceLevel`, `SurfaceScope`, `context.surfaceLevel`, `context.raisedSurfaceLevel`,
|
|
||||||
`ColorScheme.chromeSurface`, and `ThemeData.withAppSurfaces`, applied next to `withAppShapes` wherever the app
|
|
||||||
builds its theme.
|
|
||||||
- `lib/ui/color.dart`: `seededColorScheme`, the status palettes, and `delayColor`.
|
|
||||||
- `lib/views/dashboard/style.dart`: `DashboardColors` maps `page`, `card`, `tone`, and `tone2` onto the levels and
|
|
||||||
adds the dashboard's switch, series, and status colors.
|
|
||||||
|
|
||||||
## Checking A Change
|
|
||||||
|
|
||||||
Render the touched screens in light and dark, on a phone width and a desktop width, before and after. A throwaway
|
|
||||||
widget test does it: pump `HomePage` or the view inside a `RepaintBoundary`, load Roboto with `FontLoader` so text
|
|
||||||
gets real glyphs, and write `RenderRepaintBoundary.toImage` to a PNG outside the repository. Look for a surface that
|
|
||||||
matches the one it sits on, which means it skipped `SurfaceScope`, and for a tonal container fill that survived on a
|
|
||||||
card or row.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Worktrees
|
|
||||||
|
|
||||||
Claude Code, Codex, and Gemini each create git worktrees. `tool/worktrees.sh list` shows every worktree with its
|
|
||||||
owning tool and whether it holds uncommitted work; `tool/worktrees.sh prune` removes the clean ones and keeps
|
|
||||||
dirty ones unless `--force` is passed. `.claude/worktrees/` is ignored by git; `.claude/settings.json` is tracked.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"PostToolUse": [
|
|
||||||
{
|
|
||||||
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "bash \"${CLAUDE_PROJECT_DIR:-.}/tool/hooks/comment_density.sh\"",
|
|
||||||
"timeout": 15,
|
|
||||||
"statusMessage": "Checking comment density"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/core-platform
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/localization
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/pre-commit-quality-review
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/ui-work
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
[[hooks.PostToolUse]]
|
|
||||||
matcher = "apply_patch|Bash"
|
|
||||||
|
|
||||||
[[hooks.PostToolUse.hooks]]
|
|
||||||
type = "command"
|
|
||||||
command = "bash tool/hooks/comment_density.sh"
|
|
||||||
timeout = 15
|
|
||||||
statusMessage = "Checking comment density"
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"codegraph": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "codegraph",
|
|
||||||
"args": [
|
|
||||||
"serve",
|
|
||||||
"--mcp"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# freezed emits lines of trailing spaces, and generated code is never edited by hand.
|
|
||||||
lib/**/generated/** -whitespace
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
name: 问题反馈 / Bug report
|
|
||||||
title: "[BUG] "
|
|
||||||
description: 反馈你遇到的问题 / Report the issue you are experiencing
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
## 在提交问题之前,请确认以下事项:
|
|
||||||
1. 请务必给issue填写一个简洁明了的标题,以便他人快速检索
|
|
||||||
2. 请确保[已有的问题](https://github.com/chen08209/FlClash/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论
|
|
||||||
3. 请务必按照模板规范详细描述问题,否则issue将会被直接关闭
|
|
||||||
## Before submitting the issue, please make sure of the following checklist:
|
|
||||||
1. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
|
|
||||||
2. Please make sure there is no similar issue in the [existing issues](https://github.com/chen08209/FlClash/issues?q=is%3Aissue), otherwise please discuss under the existing issue
|
|
||||||
3. Please describe the problem in detail according to the template specification, otherwise issue will be closed directly.
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: 问题描述 / Describe the bug
|
|
||||||
description: 详细清晰地描述你遇到的问题,并配合截图 / Describe the problem you encountered in detail and clearly, and provide screenshots
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 软件版本 / Version
|
|
||||||
description: 请提供FlClash的具体版本 / Please provide the specific version of FlClash.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 复现步骤 / To Reproduce
|
|
||||||
description: 请提供复现问题的步骤 / Steps to reproduce the behavior
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: 操作系统 / OS
|
|
||||||
options:
|
|
||||||
- Android
|
|
||||||
- Windows
|
|
||||||
- MacOS
|
|
||||||
- Linux
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
attributes:
|
|
||||||
label: 操作系统版本 / OS Version
|
|
||||||
description: 请提供你的操作系统版本,Linux请额外提供桌面环境及窗口系统 / Please provide your OS version, for Linux, please also provide the desktop environment and window system
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 日志(勿上传日志文件,请粘贴日志内容) / Log (Do not upload the log file, paste the log content directly)
|
|
||||||
description: 请提供完整或相关部分的Debug日志(请在“软件左侧菜单”->“设置”->“日志等级”调整到debug / Please provide a complete or relevant Debug log (please adjust it to debug in the left menu of software-> Settings-> Log Level)
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
contact_links:
|
|
||||||
- name: 讨论交流 / Communication
|
|
||||||
url: https://t.me/+G-veVtwBOl4wODc1
|
|
||||||
about: 在 Telegram 群组中与其他用户讨论交流 / Communicate with other users in the Telegram group
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
name: 功能请求 / Feature request
|
|
||||||
title: "[Feature] "
|
|
||||||
description: 提出你的功能请求 / Propose your feature request
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
## 在提交问题之前,请确认以下事项:
|
|
||||||
1. 请务必给issue填写一个简洁明了的标题,以便他人快速检索
|
|
||||||
2. 请确保[已有的问题](https://github.com/chen08209/FlClash/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论
|
|
||||||
3. 请务必按照模板规范详细描述问题,否则issue将会被直接关闭
|
|
||||||
## Before submitting the issue, please make sure of the following checklist:
|
|
||||||
1. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
|
|
||||||
2. Please make sure there is no similar issue in the [existing issues](https://github.com/chen08209/FlClash/issues?q=is%3Aissue), otherwise please discuss under the existing issue
|
|
||||||
3. Please describe the problem in detail according to the template specification, otherwise issue will be closed directly.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: 功能描述 / Feature description
|
|
||||||
description: 详细清晰地描述你的功能请求 / A clear and concise description of what the feature is
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 使用场景 / Use case
|
|
||||||
description: 请描述你的功能请求的使用场景 / Please describe the use case of your feature request
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: checkboxes
|
|
||||||
id: os-labels
|
|
||||||
attributes:
|
|
||||||
label: 适用系统 / Target OS
|
|
||||||
description: 请选择该功能适用的操作系统(至少选择一个) / Please select the operating system(s) for this feature request (select at least one)
|
|
||||||
options:
|
|
||||||
- label: Android
|
|
||||||
- label: Windows
|
|
||||||
- label: MacOS
|
|
||||||
- label: Linux
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<div align=center>
|
|
||||||
|
|
||||||
[](https://img.shields.io/github/downloads/chen08209/FlClash/vVERSION/)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
**Download based on your OS:**
|
|
||||||
|
|
||||||
<div align=left>
|
|
||||||
<table>
|
|
||||||
<thead align=left>
|
|
||||||
<tr>
|
|
||||||
<th>OS</th>
|
|
||||||
<th>Download</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody align=left>
|
|
||||||
<tr>
|
|
||||||
<td>Android</td>
|
|
||||||
<td>
|
|
||||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-android-arm64-v8a.apk"><img src="https://img.shields.io/badge/APK-ARMv8-168039.svg?logo=android"></a><br>
|
|
||||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-android-armeabi-v7a.apk"><img src="https://img.shields.io/badge/APK-ARMv7-45bf55.svg?logo=android"></a><br>
|
|
||||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-android-x86_64.apk"><img src="https://img.shields.io/badge/APK-x64-96ed89.svg?logo=android"></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Windows</td>
|
|
||||||
<td>
|
|
||||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-windows-amd64-setup.exe"><img src="https://img.shields.io/badge/Setup-x64-2d7d9a.svg?logo=windows"></a><br>
|
|
||||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-windows-amd64.zip"><img src="https://img.shields.io/badge/Portable-x64-67b7d1.svg?logo=windows"></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>macOS</td>
|
|
||||||
<td>
|
|
||||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-macos-arm64.dmg"><img src="https://img.shields.io/badge/DMG-Apple%20Silicon-%23000000.svg?logo=apple"></a><br>
|
|
||||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-macos-amd64.dmg"><img src="https://img.shields.io/badge/DMG-Intel%20X64-%2300A9E0.svg?logo=apple"></a><br>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Linux</td>
|
|
||||||
<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.rpm"><img src="https://img.shields.io/badge/RpmPackage-x64-F1B42F.svg?logo=redhat"> </a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div dir="ltr">
|
|
||||||
|
|
||||||
**List of all changes:** [ChangeLog](https://github.com/chen08209/FlClash/blob/main/CHANGELOG.md)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
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:
|
|
||||||
include:
|
|
||||||
- platform: android
|
|
||||||
os: ubuntu-latest
|
|
||||||
- platform: windows
|
|
||||||
os: Windows-2022
|
|
||||||
arch: amd64
|
|
||||||
- platform: linux
|
|
||||||
os: ubuntu-22.04
|
|
||||||
arch: amd64
|
|
||||||
- platform: macos
|
|
||||||
os: macos-15-intel
|
|
||||||
arch: amd64
|
|
||||||
- platform: macos
|
|
||||||
os: macos-latest
|
|
||||||
arch: arm64
|
|
||||||
- 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
|
|
||||||
$cargoPath = "$env:USERPROFILE\.cargo\bin"
|
|
||||||
Add-Content $env:GITHUB_PATH $cargoPath
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Setup Android Signing
|
|
||||||
if: startsWith(matrix.platform,'android')
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
|
|
||||||
{
|
|
||||||
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: ${{ env.GO_VERSION }}
|
|
||||||
cache: ${{ env.BRANCH_CACHE }}
|
|
||||||
cache-dependency-path: core/go.sum
|
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
if: startsWith(matrix.platform,'android')
|
|
||||||
uses: gradle/actions/setup-gradle@v6
|
|
||||||
|
|
||||||
- name: Enable git long paths (Windows)
|
|
||||||
if: startsWith(matrix.os, 'windows')
|
|
||||||
run: git config --global core.longpaths true
|
|
||||||
|
|
||||||
# Stable publishes no Windows or Linux arm64 archive; master makes the action
|
|
||||||
# clone the SDK at the pinned tag and let it fetch the host's own Dart SDK.
|
|
||||||
- name: Setup Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: ${{ matrix.flutter-channel || 'stable' }}
|
|
||||||
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 --version
|
|
||||||
flutter pub get
|
|
||||||
|
|
||||||
# See .agents/flutter-upgrade.md before bumping FLUTTER_VERSION.
|
|
||||||
- name: Disable Impeller on Intel macOS
|
|
||||||
if: matrix.platform == 'macos' && matrix.arch == 'amd64'
|
|
||||||
run: plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist
|
|
||||||
|
|
||||||
# The core embeds this bundle and FlClashHttpOverrides.trustCoreRoots loads it for Dart.
|
|
||||||
# Pinned by hash, so a new bundle from https://curl.se/docs/caextract.html is a reviewed bump.
|
|
||||||
- name: Fetch CA certificates
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
CA_BUNDLE: cacert-2026-09-25.pem
|
|
||||||
CA_BUNDLE_SHA256: a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505
|
|
||||||
run: |
|
|
||||||
bundle=core/Clash.Meta/component/ca/ca-certificates.crt
|
|
||||||
curl -fsSL --retry 3 -o "$bundle" "https://curl.se/ca/$CA_BUNDLE"
|
|
||||||
echo "$CA_BUNDLE_SHA256 $bundle" | sha256sum -c -
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
env:
|
|
||||||
ANDROID_NDK_HOME: ${{ steps.build-ndk.outputs.ndk-path }}
|
|
||||||
run: dart setup.dart ${{ matrix.platform }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }} -v
|
|
||||||
|
|
||||||
- name: Upload
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: artifact-${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) }}
|
|
||||||
path: ./dist
|
|
||||||
overwrite: true
|
|
||||||
|
|
||||||
upload:
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
needs: [ build, dart, plugins, go, android, rust, windows-helper-test ]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
telegram-bot-api:
|
|
||||||
image: aiogram/telegram-bot-api:latest
|
|
||||||
env:
|
|
||||||
TELEGRAM_API_ID: ${{ secrets.TELEGRAM_API_ID }}
|
|
||||||
TELEGRAM_API_HASH: ${{ secrets.TELEGRAM_API_HASH }}
|
|
||||||
ports:
|
|
||||||
- 8081:8081
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Download
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: ./dist/
|
|
||||||
pattern: artifact-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Setup Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: stable
|
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
|
||||||
cache: ${{ env.BRANCH_CACHE }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Skip native build hooks
|
|
||||||
run: |
|
|
||||||
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false' pubspec.yaml
|
|
||||||
|
|
||||||
- name: Generate release notes
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
IS_STABLE: ${{ env.IS_STABLE }}
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
target=()
|
|
||||||
if [[ "$IS_STABLE" == 'true' ]]; then
|
|
||||||
target=(--tag "$TAG")
|
|
||||||
else
|
|
||||||
dart run tool/changelog.dart build --unreleased
|
|
||||||
fi
|
|
||||||
dart run tool/changelog.dart render release "${target[@]}" --out release.md
|
|
||||||
dart run tool/changelog.dart render telegram "${target[@]}" --out telegram.md
|
|
||||||
|
|
||||||
- name: Push to telegram
|
|
||||||
env:
|
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
RUN_ID: ${{ github.run_id }}
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install requests
|
|
||||||
python release_telegram.py
|
|
||||||
|
|
||||||
- name: Patch release.md
|
|
||||||
run: |
|
|
||||||
version=$(echo "${{ github.ref_name }}" | sed 's/^v//')
|
|
||||||
sed "s|VERSION|$version|g" ./.github/release_template.md >> release.md
|
|
||||||
|
|
||||||
- name: Generate sha256
|
|
||||||
if: ${{ env.IS_STABLE == 'true' }}
|
|
||||||
run: |
|
|
||||||
cd ./dist
|
|
||||||
sha256sum ./* > SHA256SUMS
|
|
||||||
|
|
||||||
- name: Release
|
|
||||||
if: ${{ env.IS_STABLE == 'true' }}
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
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: |
|
|
||||||
mkdir -p ./tmp
|
|
||||||
cp ./dist/*android-arm64-v8a* ./tmp/ || true
|
|
||||||
echo "Files copied successfully"
|
|
||||||
|
|
||||||
- name: Push to fdroid repo
|
|
||||||
if: ${{ env.IS_STABLE == 'true' }}
|
|
||||||
uses: cpina/github-action-push-to-another-repository@v1.7.2
|
|
||||||
env:
|
|
||||||
SSH_DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
|
|
||||||
with:
|
|
||||||
source-directory: ./tmp/
|
|
||||||
destination-github-username: chen08209
|
|
||||||
destination-repository-name: FlClash-fdroid-repo
|
|
||||||
user-name: 'chen08029'
|
|
||||||
user-email: 'chen08209@gmail.com'
|
|
||||||
target-branch: main
|
|
||||||
commit-message: Update from ${{ github.ref_name }}
|
|
||||||
target-directory: /tmp/
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: android
|
||||||
|
os: ubuntu-latest
|
||||||
|
- platform: windows
|
||||||
|
os: windows-latest
|
||||||
|
- platform: linux
|
||||||
|
os: ubuntu-latest
|
||||||
|
- platform: macos
|
||||||
|
os: macos-13
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup JAVA
|
||||||
|
if: startsWith(matrix.platform,'android')
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
|
- name: Setup NDK
|
||||||
|
if: startsWith(matrix.platform,'android')
|
||||||
|
uses: nttld/setup-ndk@v1
|
||||||
|
id: setup-ndk
|
||||||
|
with:
|
||||||
|
ndk-version: r26b
|
||||||
|
add-to-path: true
|
||||||
|
link-to-sdk: true
|
||||||
|
|
||||||
|
- name: Setup Android Signing
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: 'core/go.mod'
|
||||||
|
cache-dependency-path: |
|
||||||
|
core/go.sum
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.x'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Get Flutter Dependency
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
run: |
|
||||||
|
dart setup.dart ${{ matrix.platform }}
|
||||||
|
|
||||||
|
- name: Upload
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: artifact-${{ matrix.platform }}
|
||||||
|
path: ./dist
|
||||||
|
retention-days: 1
|
||||||
|
overwrite: true
|
||||||
|
|
||||||
|
|
||||||
|
upload-release:
|
||||||
|
if: ${{ !endsWith(github.ref, '-debug') }}
|
||||||
|
permissions: write-all
|
||||||
|
needs: [ build ]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Download
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ./dist/
|
||||||
|
pattern: artifact-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Pre Release
|
||||||
|
run: |
|
||||||
|
pip install gitchangelog pystache mustache markdown
|
||||||
|
pre=$(curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" | grep -Po '"tag_name": "\K.*?(?=")' || echo "")
|
||||||
|
if [ -z "pre" ]; then
|
||||||
|
echo "init" > release.md
|
||||||
|
else
|
||||||
|
current="${{ github.ref_name }}"
|
||||||
|
echo -e "\n\n<details markdown=1><summary>All changes from $current to the latest commit:</summary>\n\n" >> release.md
|
||||||
|
gitchangelog "${pre}.." >> release.md 2>&1 || echo "Error in gitchangelog"
|
||||||
|
echo -e "\n\n</details>" >> release.md
|
||||||
|
fi
|
||||||
|
- name: Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: ./dist/*
|
||||||
|
body_path: './release.md'
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
name: pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- 'site/**'
|
|
||||||
- 'tool/build_site.sh'
|
|
||||||
- 'tool/render_site.mjs'
|
|
||||||
- '.github/workflows/pages.yaml'
|
|
||||||
workflow_run:
|
|
||||||
workflows:
|
|
||||||
- build
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: pages
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
tag:
|
|
||||||
# head_branch also names a branch push's branch, so only a tag at the built commit counts as a release.
|
|
||||||
if: >-
|
|
||||||
github.repository == 'chen08209/FlClash' &&
|
|
||||||
github.event_name == 'workflow_run' &&
|
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
|
||||||
startsWith(github.event.workflow_run.head_branch, 'v') &&
|
|
||||||
!contains(github.event.workflow_run.head_branch, '-')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
stable: ${{ steps.lookup.outputs.stable }}
|
|
||||||
steps:
|
|
||||||
- name: Look up the tag
|
|
||||||
id: lookup
|
|
||||||
env:
|
|
||||||
NAME: ${{ github.event.workflow_run.head_branch }}
|
|
||||||
SHA: ${{ github.event.workflow_run.head_sha }}
|
|
||||||
REMOTE: ${{ github.server_url }}/${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
if git ls-remote --tags "$REMOTE" "refs/tags/$NAME" "refs/tags/$NAME^{}" | grep -q "^$SHA"; then
|
|
||||||
echo 'stable=true' >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
needs: tag
|
|
||||||
if: >-
|
|
||||||
github.repository == 'chen08209/FlClash' &&
|
|
||||||
!cancelled() &&
|
|
||||||
(github.event_name != 'workflow_run' || needs.tag.outputs.stable == 'true')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Assemble site
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: bash tool/build_site.sh _site
|
|
||||||
|
|
||||||
- name: Configure Pages
|
|
||||||
uses: actions/configure-pages@v5
|
|
||||||
|
|
||||||
- name: Upload
|
|
||||||
uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: _site
|
|
||||||
|
|
||||||
- name: Deploy
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
@@ -5,11 +5,9 @@
|
|||||||
*.swp
|
*.swp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.atom/
|
.atom/
|
||||||
.build/
|
|
||||||
.buildlog/
|
.buildlog/
|
||||||
.history
|
.history
|
||||||
.svn/
|
.svn/
|
||||||
.swiftpm/
|
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
@@ -21,7 +19,7 @@ migrate_working_dir/
|
|||||||
# The .vscode folder contains launch configuration and tasks you configure in
|
# 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
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
# is commented out by default.
|
# is commented out by default.
|
||||||
.vscode/
|
#.vscode/
|
||||||
|
|
||||||
# Flutter/Dart/Pub related
|
# Flutter/Dart/Pub related
|
||||||
**/doc/api/
|
**/doc/api/
|
||||||
@@ -41,40 +39,15 @@ app.*.symbols
|
|||||||
# Obfuscation related
|
# Obfuscation related
|
||||||
app.*.map.json
|
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 Studio will place build artifacts here
|
||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/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/
|
|
||||||
|
|
||||||
#FlClash
|
|
||||||
|
|
||||||
|
#libclash
|
||||||
/libclash/
|
/libclash/
|
||||||
/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
|
#jniLibs
|
||||||
.fvm/
|
/android/app/src/main/jniLibs/
|
||||||
.fvmrc
|
|
||||||
/coverage/lcov.info
|
|
||||||
|
|||||||
@@ -2,3 +2,7 @@
|
|||||||
path = core/Clash.Meta
|
path = core/Clash.Meta
|
||||||
url = git@github.com:chen08209/Clash.Meta.git
|
url = git@github.com:chen08209/Clash.Meta.git
|
||||||
branch = FlClash
|
branch = FlClash
|
||||||
|
[submodule "plugins/flutter_distributor"]
|
||||||
|
path = plugins/flutter_distributor
|
||||||
|
url = git@github.com:chen08209/flutter_distributor.git
|
||||||
|
branch = main
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"codegraph": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "codegraph",
|
|
||||||
"args": [
|
|
||||||
"serve",
|
|
||||||
"--mcp"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# This file tracks properties of this Flutter project.
|
# This file tracks properties of this Flutter project.
|
||||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
#
|
#
|
||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||||
channel: "stable"
|
channel: stable
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
|
|
||||||
@@ -13,11 +13,26 @@ project_type: app
|
|||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
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
|
||||||
- platform: windows
|
- platform: windows
|
||||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
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]
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="main.dart (safe mode)" type="FlutterRunConfigurationType" factoryName="Flutter">
|
|
||||||
<option name="additionalArgs" value="--dart-define-from-file env.json --dart-define=SAFE_MODE=true" />
|
|
||||||
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"label": "Flutter App (With Env)",
|
|
||||||
"adapter": "Dart",
|
|
||||||
"type": "flutter",
|
|
||||||
"request": "launch",
|
|
||||||
"program": "lib/main.dart",
|
|
||||||
"cwd": "$ZED_WORKTREE_ROOT",
|
|
||||||
"toolArgs": ["--dart-define-from-file", "env.json"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Flutter App (Safe Mode)",
|
|
||||||
"adapter": "Dart",
|
|
||||||
"type": "flutter",
|
|
||||||
"request": "launch",
|
|
||||||
"program": "lib/main.dart",
|
|
||||||
"cwd": "$ZED_WORKTREE_ROOT",
|
|
||||||
"toolArgs": [
|
|
||||||
"--dart-define-from-file",
|
|
||||||
"env.json",
|
|
||||||
"--dart-define=SAFE_MODE=true",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"lsp": {
|
|
||||||
"gopls": {
|
|
||||||
"binary": {
|
|
||||||
"env": {
|
|
||||||
"CGO_ENABLED": "0",
|
|
||||||
// "GOOS": "android",
|
|
||||||
// "GOARCH": "arm64",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"label": "flutter run (With Env)",
|
|
||||||
"command": "flutter",
|
|
||||||
"args": ["run", "--dart-define-from-file", "env.json"],
|
|
||||||
"cwd": "$ZED_WORKTREE_ROOT",
|
|
||||||
"use_new_terminal": true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "flutter run (Safe Mode)",
|
|
||||||
"command": "flutter",
|
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"--dart-define-from-file",
|
|
||||||
"env.json",
|
|
||||||
"--dart-define=SAFE_MODE=true",
|
|
||||||
],
|
|
||||||
"cwd": "$ZED_WORKTREE_ROOT",
|
|
||||||
"use_new_terminal": true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,779 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## v0.9.0 (2026-10-14)
|
|
||||||
|
|
||||||
**Breaking Changes**
|
|
||||||
|
|
||||||
- **views** DNS and NTP override toggles are removed; picked keys apply to every profile (491fae1)
|
|
||||||
- **database** Custom overwrites are removed; rebuild them as custom profiles (1677024)
|
|
||||||
|
|
||||||
**Features**
|
|
||||||
|
|
||||||
- **views** Per-key DNS, NTP, sniffer and TUN settings (491fae1)
|
|
||||||
- **profiles** Custom profiles (3abda78)
|
|
||||||
- **ui** Stacked sheets, context menus and a sort mode (8c43053)
|
|
||||||
- **providers** DNS, Hosts and ports apply without a restart (532ebf0)
|
|
||||||
- **android** Launcher shortcuts and one-step on-demand access (d3fa225)
|
|
||||||
- **core** Share link import and mrs rule sets (2fa2f7f)
|
|
||||||
|
|
||||||
**Bug Fixes**
|
|
||||||
|
|
||||||
- **database** Unreadable settings no longer block startup or restores (1677024)
|
|
||||||
- **desktop** Windows and Linux desktop fixes (94a25f4)
|
|
||||||
- **core,helper** The elevated Core only answers its owner (d780cb8)
|
|
||||||
|
|
||||||
## v0.8.99 (2026-10-03)
|
|
||||||
|
|
||||||
**Features**
|
|
||||||
|
|
||||||
- **editor** Editor word wrap and snippets (e131675)
|
|
||||||
- **dashboard** More dashboard cards (e62afb7)
|
|
||||||
- **views** Hide timed-out nodes and search every list (8378c70)
|
|
||||||
- **ui** Sidebar, floating dock and drag to go back (841d603)
|
|
||||||
- **ui** New app glyphs, empty states and theme page (ee6a497)
|
|
||||||
- **overwrite** Invalid override warnings and rule presets (46fb7fe)
|
|
||||||
- **config** DNS query log and per-key DNS/NTP overrides (a390a42)
|
|
||||||
- **core** Proxied service checks and outbound IP risk (8068e68)
|
|
||||||
- **platform** Tray hotkeys, node delays and a delay test (1e97716)
|
|
||||||
|
|
||||||
**Bug Fixes**
|
|
||||||
|
|
||||||
- **core** A changed resource link is now used by the next sync and by auto update (ff23f30)
|
|
||||||
- **core** DNS lookups no longer fail on networks that report no DNS servers (e55f6d1)
|
|
||||||
- **proxies** Smaller icons in icon-only group headers (786a6ab)
|
|
||||||
- **core,android** Push notifications reconnect in Doze (1e9955d)
|
|
||||||
- **backup,database** Restores never apply halfway (bee8f24)
|
|
||||||
|
|
||||||
## v0.8.98 (2026-09-14)
|
|
||||||
|
|
||||||
**Bug Fixes**
|
|
||||||
|
|
||||||
- **resources** Refresh the geo file size and time after an update finishes (c5bf5bd)
|
|
||||||
- **core** Keep the core running while Windows sleeps with the app suspended (60f371a)
|
|
||||||
|
|
||||||
## v0.8.97 (2026-09-10)
|
|
||||||
|
|
||||||
**Features**
|
|
||||||
|
|
||||||
- **ui** Rework the app UI and refresh the localization (26cfbaf)
|
|
||||||
- **app** Rework the app layer and window handling, and add proxy authentication (aaf934c)
|
|
||||||
- **desktop** Rework the desktop runners, packaging, and native build (c0fcbc0)
|
|
||||||
- **android** Rework the Android VPN service and lifecycle handling (ae29f38)
|
|
||||||
- **plugins** Rework the desktop plugins and add the Helper service and Rust bridge (adf715f)
|
|
||||||
- **core** Rework the core IPC and process lifecycle (c6eaa0a)
|
|
||||||
|
|
||||||
<!-- changelog:frozen -->
|
|
||||||
<!-- Entries below predate the structured pipeline. Their wording is kept as written; only the heading and list style were normalized. -->
|
|
||||||
|
|
||||||
## v0.8.96 (2026-08-17)
|
|
||||||
|
|
||||||
- Optimize commented policy
|
|
||||||
- Fix whole group delay test failing on Windows
|
|
||||||
- Optimize package icon loading and connections polling
|
|
||||||
|
|
||||||
## v0.8.95 (2026-08-14)
|
|
||||||
|
|
||||||
- Optimize core service
|
|
||||||
- Optimize Android TV launcher icon
|
|
||||||
- Optimize back navigation
|
|
||||||
- Optimize more details
|
|
||||||
- Fix some issues
|
|
||||||
- Optimize app layout
|
|
||||||
- Optimize focus control
|
|
||||||
- Adjust android process
|
|
||||||
|
|
||||||
## v0.8.94 (2026-07-11)
|
|
||||||
|
|
||||||
- Fix macos performance issue
|
|
||||||
- Support custom global-ua
|
|
||||||
- Update core
|
|
||||||
- Optimize some details
|
|
||||||
- Fix linux silent launching not working
|
|
||||||
|
|
||||||
## v0.8.93 (2026-05-29)
|
|
||||||
|
|
||||||
- Support custom overwrite
|
|
||||||
- Support run on demand
|
|
||||||
- Optimize windows ipc
|
|
||||||
- Optimize windows arm64
|
|
||||||
- Optimize build
|
|
||||||
- Optimize some details
|
|
||||||
- Update core
|
|
||||||
|
|
||||||
## v0.8.92 (2026-02-02)
|
|
||||||
|
|
||||||
- Add sqlite store
|
|
||||||
- Optimize android quick action
|
|
||||||
- Optimize backup and restore
|
|
||||||
- Optimize more details
|
|
||||||
|
|
||||||
## v0.8.91 (2025-12-12)
|
|
||||||
|
|
||||||
- Fix windows some issues
|
|
||||||
- Optimize overwrite handle
|
|
||||||
- Optimize access control page
|
|
||||||
- Optimize some details
|
|
||||||
|
|
||||||
## v0.8.90 (2025-10-08)
|
|
||||||
|
|
||||||
- Fix android tile service
|
|
||||||
- Support append system DNS
|
|
||||||
- Fix some issues
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.89 (2025-09-27)
|
|
||||||
|
|
||||||
- Fix some issues
|
|
||||||
- Optimize Windows service mode
|
|
||||||
- Update core
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.88 (2025-09-23)
|
|
||||||
|
|
||||||
- Add android separates the core process
|
|
||||||
- Support core status check and force restart
|
|
||||||
- Optimize proxies page and access page
|
|
||||||
- Update flutter and pub dependencies
|
|
||||||
- Update go version
|
|
||||||
- Optimize more details
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.87 (2025-07-29)
|
|
||||||
|
|
||||||
- Optimize desktop view
|
|
||||||
- Optimize logs, requests, connection pages
|
|
||||||
- Optimize windows tray auto hide
|
|
||||||
- Optimize some details
|
|
||||||
- Update core
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.86 (2025-06-15)
|
|
||||||
|
|
||||||
- Fix windows tun issues
|
|
||||||
- Optimize android get system dns
|
|
||||||
- Optimize more details
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.85 (2025-06-07)
|
|
||||||
|
|
||||||
- Support override script
|
|
||||||
- Support proxies search
|
|
||||||
- Support svg display
|
|
||||||
- Optimize config persistence
|
|
||||||
- Add some scenes auto close connections
|
|
||||||
- Update core
|
|
||||||
- Optimize more details
|
|
||||||
|
|
||||||
## v0.8.84 (2025-05-01)
|
|
||||||
|
|
||||||
- Fix windows service verify issues
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.83 (2025-05-01)
|
|
||||||
|
|
||||||
- Add windows server mode start process verify
|
|
||||||
- Add linux deb dependencies
|
|
||||||
- Add backup recovery strategy select
|
|
||||||
- Support custom text scaling
|
|
||||||
- Optimize the display of different text scale
|
|
||||||
- Optimize windows setup experience
|
|
||||||
- Optimize startTun performance
|
|
||||||
- Optimize android tv experience
|
|
||||||
- Optimize default option
|
|
||||||
- Optimize computed text size
|
|
||||||
- Optimize hyperOS freeform window
|
|
||||||
- Add developer mode
|
|
||||||
- Update core
|
|
||||||
- Optimize more details
|
|
||||||
- Add issues template
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.82 (2025-04-18)
|
|
||||||
|
|
||||||
- Optimize android vpn performance
|
|
||||||
- Add custom primary color and color scheme
|
|
||||||
- Add linux nad windows arm release
|
|
||||||
- Optimize requests and logs page
|
|
||||||
- Fix map input page delete issues
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.81 (2025-04-08)
|
|
||||||
|
|
||||||
- Add rule override
|
|
||||||
- Update core
|
|
||||||
- Optimize more details
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.80 (2025-03-10)
|
|
||||||
|
|
||||||
- Optimize dashboard performance
|
|
||||||
- Fix some issues
|
|
||||||
- Fix unselected proxy group delay issues
|
|
||||||
- Fix asn url issues
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.79 (2025-03-07)
|
|
||||||
|
|
||||||
- Fix tab delay view issues
|
|
||||||
- Fix tray action issues
|
|
||||||
- Fix get profile redirect client ua issues
|
|
||||||
- Fix proxy card delay view issues
|
|
||||||
- Add Russian, Japanese adaptation
|
|
||||||
- Fix some issues
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.78 (2025-03-05)
|
|
||||||
|
|
||||||
- Fix list form input view issues
|
|
||||||
- Fix traffic view issues
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.77 (2025-03-05)
|
|
||||||
|
|
||||||
- Optimize performance
|
|
||||||
- Update core
|
|
||||||
- Optimize core stability
|
|
||||||
- Fix linux tun authority check error
|
|
||||||
- Fix some issues
|
|
||||||
- Fix scroll physics error
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.75 (2025-02-09)
|
|
||||||
|
|
||||||
- Add windows storage corruption detection
|
|
||||||
- Fix core crash caused by windows resource manager restart
|
|
||||||
- Optimize logs, requests, access to pages
|
|
||||||
- Fix macos bypass domain issues
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.74 (2025-02-03)
|
|
||||||
|
|
||||||
- Fix some issues
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.73 (2025-02-02)
|
|
||||||
|
|
||||||
- Update popup menu
|
|
||||||
- Add file editor
|
|
||||||
- Fix android service issues
|
|
||||||
- Optimize desktop background performance
|
|
||||||
- Optimize android main process performance
|
|
||||||
- Optimize delay test
|
|
||||||
- Optimize vpn protect
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.72 (2025-01-10)
|
|
||||||
|
|
||||||
- Update core
|
|
||||||
- Fix some issues
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.71 (2025-01-09)
|
|
||||||
|
|
||||||
- Remake dashboard
|
|
||||||
- Optimize theme
|
|
||||||
- Optimize more details
|
|
||||||
- Update flutter version
|
|
||||||
- Update changelog
|
|
||||||
|
|
||||||
## v0.8.70 (2024-12-09)
|
|
||||||
|
|
||||||
- Support better window position memory
|
|
||||||
- Add windows arm64 and linux arm64 build script
|
|
||||||
- Optimize some details
|
|
||||||
|
|
||||||
## v0.8.69 (2024-12-06)
|
|
||||||
|
|
||||||
- Remake desktop
|
|
||||||
- Optimize change proxy
|
|
||||||
- Optimize network check
|
|
||||||
- Fix fallback issues
|
|
||||||
- Optimize lots of details
|
|
||||||
- Update change.yaml
|
|
||||||
- Fix android tile issues
|
|
||||||
- Fix windows tray issues
|
|
||||||
- Support setting bypassDomain
|
|
||||||
- Update flutter version
|
|
||||||
- Fix android service issues
|
|
||||||
- Fix macos dock exit button issues
|
|
||||||
- Add route address setting
|
|
||||||
- Optimize provider view
|
|
||||||
- Update changelog
|
|
||||||
- Update CHANGELOG.md
|
|
||||||
|
|
||||||
## v0.8.67 (2024-11-09)
|
|
||||||
|
|
||||||
- Add android shortcuts
|
|
||||||
- Fix init params issues
|
|
||||||
- Fix dynamic color issues
|
|
||||||
- Optimize navigator animate
|
|
||||||
- Optimize window init
|
|
||||||
- Optimize fab
|
|
||||||
- Optimize save
|
|
||||||
|
|
||||||
## v0.8.66 (2024-10-26)
|
|
||||||
|
|
||||||
- Fix the collapse issues
|
|
||||||
- Add fontFamily options
|
|
||||||
|
|
||||||
## v0.8.65 (2024-10-26)
|
|
||||||
|
|
||||||
- Update core version
|
|
||||||
- Update flutter version
|
|
||||||
- Optimize ip check
|
|
||||||
- Optimize url-test
|
|
||||||
|
|
||||||
## v0.8.64 (2024-10-12)
|
|
||||||
|
|
||||||
- Update release message
|
|
||||||
- Init auto gen changelog
|
|
||||||
- Fix windows tray issues
|
|
||||||
- Fix urltest issues
|
|
||||||
- Add auto changelog
|
|
||||||
- Fix windows admin auto launch issues
|
|
||||||
- Add android vpn options
|
|
||||||
- Support proxies icon configuration
|
|
||||||
- Optimize android immersion display
|
|
||||||
- Fix some issues
|
|
||||||
- Optimize ip detection
|
|
||||||
- Support android vpn ipv6 inbound switch
|
|
||||||
- Support log export
|
|
||||||
- Optimize more details
|
|
||||||
- Fix android system dns issues
|
|
||||||
- Optimize dns default option
|
|
||||||
- Fix some issues
|
|
||||||
- Update readme
|
|
||||||
|
|
||||||
## v0.8.60 (2024-09-17)
|
|
||||||
|
|
||||||
- Fix build error2
|
|
||||||
- Fix build error
|
|
||||||
- Support desktop hotkey
|
|
||||||
- Support android ipv6 inbound
|
|
||||||
- Support android system dns
|
|
||||||
- fix some bugs
|
|
||||||
|
|
||||||
## v0.8.59 (2024-09-09)
|
|
||||||
|
|
||||||
- Fix delete profile error
|
|
||||||
|
|
||||||
## v0.8.58 (2024-09-08)
|
|
||||||
|
|
||||||
- Fix submit error 2
|
|
||||||
- Fix submit error
|
|
||||||
- Optimize DNS strategy
|
|
||||||
- Fix the problem that the tray is not displayed in some cases
|
|
||||||
- Optimize tray
|
|
||||||
- Update core
|
|
||||||
- Fix some error
|
|
||||||
|
|
||||||
## v0.8.57 (2024-09-02)
|
|
||||||
|
|
||||||
- Fix tun update issues
|
|
||||||
- Add DNS override
|
|
||||||
- Fixed some bugs
|
|
||||||
- Optimize more detail
|
|
||||||
- Add Hosts override
|
|
||||||
|
|
||||||
## v0.8.56 (2024-08-26)
|
|
||||||
|
|
||||||
- fix android tip error
|
|
||||||
- fix windows auto launch error
|
|
||||||
|
|
||||||
## v0.8.55 (2024-08-25)
|
|
||||||
|
|
||||||
- Fix windows tray issues
|
|
||||||
- Optimize windows logic
|
|
||||||
- Optimize app logic
|
|
||||||
- Support windows administrator auto launch
|
|
||||||
- Support android close vpn
|
|
||||||
|
|
||||||
## v0.8.53 (2024-08-15)
|
|
||||||
|
|
||||||
- Change flutter version
|
|
||||||
- Support profiles sort
|
|
||||||
- Support windows country flags display
|
|
||||||
- Optimize proxies page and profiles page columns
|
|
||||||
|
|
||||||
## v0.8.52 (2024-08-11)
|
|
||||||
|
|
||||||
- Update flutter version
|
|
||||||
- Update version
|
|
||||||
- Update timeout time
|
|
||||||
- Update access control page
|
|
||||||
- Fix bug
|
|
||||||
|
|
||||||
## v0.8.51 (2024-08-05)
|
|
||||||
|
|
||||||
- Optimize provider page
|
|
||||||
- Optimize delay test
|
|
||||||
- Support local backup and recovery
|
|
||||||
- Fix android tile service issues
|
|
||||||
|
|
||||||
## v0.8.49 (2024-07-31)
|
|
||||||
|
|
||||||
- Fix linux core build error
|
|
||||||
- Add proxy-only traffic statistics
|
|
||||||
- Update core
|
|
||||||
- Optimize more details
|
|
||||||
- Merge pull request #140 from txyyh/main
|
|
||||||
- 添加自建 F-Droid 仓库相关 workflow
|
|
||||||
- Rename readme fingerprint
|
|
||||||
- Rename workflow deploy repo name
|
|
||||||
- Add download guide to README
|
|
||||||
- Add push release files to fdroid-repo
|
|
||||||
|
|
||||||
## v0.8.48 (2024-07-25)
|
|
||||||
|
|
||||||
- Optimize proxies page
|
|
||||||
- Fix ua issues
|
|
||||||
- Optimize more details
|
|
||||||
|
|
||||||
## v0.8.47 (2024-07-22)
|
|
||||||
|
|
||||||
- Fix windows build error
|
|
||||||
|
|
||||||
## v0.8.46 (2024-07-22)
|
|
||||||
|
|
||||||
- Update app icon
|
|
||||||
- Fix desktop backup error
|
|
||||||
- Optimize request ua
|
|
||||||
- Change android icon
|
|
||||||
- Optimize dashboard
|
|
||||||
|
|
||||||
## v0.8.44 (2024-07-18)
|
|
||||||
|
|
||||||
- Remove request validate certificate
|
|
||||||
- Sync core
|
|
||||||
|
|
||||||
## v0.8.43 (2024-07-18)
|
|
||||||
|
|
||||||
- Fix windows error
|
|
||||||
|
|
||||||
## v0.8.42 (2024-07-18)
|
|
||||||
|
|
||||||
- Fix setup.dart error
|
|
||||||
- Fix android system proxy not effective
|
|
||||||
- Add macos arm64
|
|
||||||
|
|
||||||
## v0.8.41 (2024-07-17)
|
|
||||||
|
|
||||||
- Optimize proxies page
|
|
||||||
- Support mouse drag scroll
|
|
||||||
- Adjust desktop ui
|
|
||||||
- Revert "Fix android vpn issues"
|
|
||||||
- This reverts commit 891977408e6938e2acd74e9b9adb959c48c79988.
|
|
||||||
|
|
||||||
## v0.8.40 (2024-07-15)
|
|
||||||
|
|
||||||
- Fix android vpn issues
|
|
||||||
- Fix android vpn issues
|
|
||||||
- Rollback partial modification
|
|
||||||
|
|
||||||
## v0.8.39 (2024-07-15)
|
|
||||||
|
|
||||||
- Fix the problem that ui can't be synchronized when android vpn is occupied by an external
|
|
||||||
- Override default socksPort,port
|
|
||||||
|
|
||||||
## v0.8.38 (2024-07-14)
|
|
||||||
|
|
||||||
- Fix fab issues
|
|
||||||
|
|
||||||
## v0.8.37 (2024-07-14)
|
|
||||||
|
|
||||||
- Update version
|
|
||||||
- Fix the problem that vpn cannot be started in some cases
|
|
||||||
- Fix the problem that geodata url does not take effect
|
|
||||||
|
|
||||||
## v0.8.36 (2024-07-13)
|
|
||||||
|
|
||||||
- Update ua
|
|
||||||
- Fix change outbound mode without check ip issues
|
|
||||||
- Separate android ui and vpn
|
|
||||||
- Fix url validate issues 2
|
|
||||||
- Add android hidden from the recent task
|
|
||||||
- Add geoip file
|
|
||||||
- Support modify geoData URL
|
|
||||||
|
|
||||||
## v0.8.35 (2024-07-07)
|
|
||||||
|
|
||||||
- Fix url validate issues
|
|
||||||
- Fix check ip performance problem
|
|
||||||
- Optimize resources page
|
|
||||||
|
|
||||||
## v0.8.34 (2024-07-04)
|
|
||||||
|
|
||||||
- Add ua selector
|
|
||||||
- Support modify test url
|
|
||||||
- Optimize android proxy
|
|
||||||
- Fix the error that async proxy provider could not selected the proxy
|
|
||||||
|
|
||||||
## v0.8.33 (2024-07-01)
|
|
||||||
|
|
||||||
- Fix android proxy error
|
|
||||||
- Fix submit error
|
|
||||||
- Add windows tun
|
|
||||||
- Optimize android proxy
|
|
||||||
- Optimize change profile
|
|
||||||
- Update application ua
|
|
||||||
- Optimize delay test
|
|
||||||
|
|
||||||
## v0.8.32 (2024-06-28)
|
|
||||||
|
|
||||||
- Fix android repeated request notification issues
|
|
||||||
|
|
||||||
## v0.8.31 (2024-06-28)
|
|
||||||
|
|
||||||
- Fix memory overflow issues
|
|
||||||
|
|
||||||
## v0.8.30 (2024-06-27)
|
|
||||||
|
|
||||||
- Optimize proxies expansion panel 2
|
|
||||||
- Fix android scan qrcode error
|
|
||||||
|
|
||||||
## v0.8.29 (2024-06-27)
|
|
||||||
|
|
||||||
- Optimize proxies expansion panel
|
|
||||||
- Fix text error
|
|
||||||
|
|
||||||
## v0.8.28 (2024-06-26)
|
|
||||||
|
|
||||||
- Optimize proxy
|
|
||||||
- Optimize delayed sorting performance
|
|
||||||
- Add expansion panel proxies page
|
|
||||||
- Support to adjust the proxy card size
|
|
||||||
- Support to adjust proxies columns number
|
|
||||||
- Fix autoRun show issues
|
|
||||||
- Fix Android 10 issues
|
|
||||||
- Optimize ip show
|
|
||||||
|
|
||||||
## v0.8.26 (2024-06-22)
|
|
||||||
|
|
||||||
- Add intranet IP display
|
|
||||||
- Add connections page
|
|
||||||
- Add search in connections, requests
|
|
||||||
- Add keyword search in connections, requests, logs
|
|
||||||
- Add basic viewing editing capabilities
|
|
||||||
- Optimize update profile
|
|
||||||
|
|
||||||
## v0.8.25 (2024-06-19)
|
|
||||||
|
|
||||||
- Update version
|
|
||||||
- Fix the problem of excessive memory usage in traffic usage.
|
|
||||||
- Add lightBlue theme color
|
|
||||||
- Fix start unable to update profile issues
|
|
||||||
- Fix flashback caused by process
|
|
||||||
|
|
||||||
## v0.8.23 (2024-06-16)
|
|
||||||
|
|
||||||
- Add build version
|
|
||||||
- Optimize quick start
|
|
||||||
- Update system default option
|
|
||||||
|
|
||||||
## v0.8.22 (2024-06-16)
|
|
||||||
|
|
||||||
- Update build.yml
|
|
||||||
- Fix android vpn close issues
|
|
||||||
- Add requests page
|
|
||||||
- Fix checkUpdate dark mode style error
|
|
||||||
- Fix quickStart error open app
|
|
||||||
- Add memory proxies tab index
|
|
||||||
- Support hidden group
|
|
||||||
- Optimize logs
|
|
||||||
- Fix externalController hot load error
|
|
||||||
|
|
||||||
## v0.8.21 (2024-06-13)
|
|
||||||
|
|
||||||
- Add tcp concurrent switch
|
|
||||||
- Add system proxy switch
|
|
||||||
- Add geodata loader switch
|
|
||||||
- Add external controller switch
|
|
||||||
- Add auto gc on trim memory
|
|
||||||
- Fix android notification error
|
|
||||||
|
|
||||||
## v0.8.20 (2024-06-12)
|
|
||||||
|
|
||||||
- Fix ipv6 error
|
|
||||||
- Fix android udp direct error
|
|
||||||
- Add ipv6 switch
|
|
||||||
- Add access all selected button
|
|
||||||
- Remove android low version splash
|
|
||||||
|
|
||||||
## v0.8.19 (2024-06-10)
|
|
||||||
|
|
||||||
- Update version
|
|
||||||
- Add allowBypass
|
|
||||||
- Fix Android only pick .text file issues
|
|
||||||
|
|
||||||
## v0.8.18 (2024-06-09)
|
|
||||||
|
|
||||||
- Fix search issues
|
|
||||||
|
|
||||||
## v0.8.17 (2024-06-09)
|
|
||||||
|
|
||||||
- Fix LoadBalance, Relay load error
|
|
||||||
- Fix build.yml4
|
|
||||||
- Fix build.yml3
|
|
||||||
- Fix build.yml2
|
|
||||||
- Fix build.yml
|
|
||||||
- Add search function at access control
|
|
||||||
- Fix the issues with the profile add button to cover the edit button
|
|
||||||
- Adapt LoadBalance and Relay
|
|
||||||
- Add arm
|
|
||||||
- Fix android notification icon error
|
|
||||||
|
|
||||||
## v0.8.16 (2024-06-08)
|
|
||||||
|
|
||||||
- Add one-click update all profiles
|
|
||||||
- Add expire show
|
|
||||||
|
|
||||||
## v0.8.15 (2024-06-06)
|
|
||||||
|
|
||||||
- Temp remove tun mode
|
|
||||||
- Remove macos in workflow
|
|
||||||
- Change go version
|
|
||||||
|
|
||||||
## v0.8.14 (2024-06-06)
|
|
||||||
|
|
||||||
- Update Version
|
|
||||||
- Fix tun unable to open
|
|
||||||
|
|
||||||
## v0.8.13 (2024-06-06)
|
|
||||||
|
|
||||||
- Optimize delay test2
|
|
||||||
- Optimize delay test
|
|
||||||
- Add check ip
|
|
||||||
- add check ip request
|
|
||||||
|
|
||||||
## v0.8.12 (2024-06-06)
|
|
||||||
|
|
||||||
- Fix the problem that the download of remote resources failed after GeodataMode was turned on, which caused the
|
|
||||||
application to flash back.
|
|
||||||
- Fix edit profile error
|
|
||||||
- Fix quickStart change proxy error
|
|
||||||
- Fix core version
|
|
||||||
|
|
||||||
## v0.8.10 (2024-06-05)
|
|
||||||
|
|
||||||
- Fix core version
|
|
||||||
|
|
||||||
## v0.8.9 (2024-06-05)
|
|
||||||
|
|
||||||
- Update file_picker
|
|
||||||
- Add resources page
|
|
||||||
- Optimize more detail
|
|
||||||
- Add access selected sorted
|
|
||||||
- Fix notification duplicate creation issue
|
|
||||||
- Fix AccessControl click issue
|
|
||||||
|
|
||||||
## v0.8.7 (2024-05-31)
|
|
||||||
|
|
||||||
- Fix Workflow
|
|
||||||
- Fix Linux unable to open
|
|
||||||
- Update README.md 3
|
|
||||||
- Create LICENSE
|
|
||||||
- Update README.md 2
|
|
||||||
- Update README.md
|
|
||||||
- Optimize workFlow
|
|
||||||
|
|
||||||
## v0.8.6 (2024-05-31)
|
|
||||||
|
|
||||||
- optimize checkUpdate
|
|
||||||
|
|
||||||
## v0.8.5 (2024-05-30)
|
|
||||||
|
|
||||||
- Fix submit error
|
|
||||||
|
|
||||||
## v0.8.4 (2024-05-30)
|
|
||||||
|
|
||||||
- add WebDAV
|
|
||||||
- add Auto check updates
|
|
||||||
- Optimize more details
|
|
||||||
- optimize delayTest
|
|
||||||
|
|
||||||
## v0.8.2 (2024-05-15)
|
|
||||||
|
|
||||||
- upgrade flutter version
|
|
||||||
|
|
||||||
## v0.8.1 (2024-05-15)
|
|
||||||
|
|
||||||
- Update kernel
|
|
||||||
- Add import profile via QR code image
|
|
||||||
|
|
||||||
## v0.8.0 (2024-05-11)
|
|
||||||
|
|
||||||
- Add compatibility mode and adapt clash scheme.
|
|
||||||
|
|
||||||
## v0.7.14 (2024-05-07)
|
|
||||||
|
|
||||||
- update Version
|
|
||||||
- Reconstruction application proxy logic
|
|
||||||
|
|
||||||
## v0.7.13 (2024-05-06)
|
|
||||||
|
|
||||||
- Fix Tab destroy error
|
|
||||||
|
|
||||||
## v0.7.12 (2024-05-06)
|
|
||||||
|
|
||||||
- Optimize repeat healthcheck
|
|
||||||
|
|
||||||
## v0.7.11 (2024-05-06)
|
|
||||||
|
|
||||||
- Optimize Direct mode ui
|
|
||||||
|
|
||||||
## v0.7.10 (2024-05-06)
|
|
||||||
|
|
||||||
- Optimize Healthcheck
|
|
||||||
- Remove proxies position animation, improve performance
|
|
||||||
- Add Telegram Link
|
|
||||||
- Update healthcheck policy
|
|
||||||
- New Check URLTest
|
|
||||||
- Fix the problem of invalid auto-selection
|
|
||||||
|
|
||||||
## v0.7.8 (2024-05-05)
|
|
||||||
|
|
||||||
- New Async UpdateConfig
|
|
||||||
- add changeProfileDebounce
|
|
||||||
- Update Workflow
|
|
||||||
- Fix ChangeProfile block
|
|
||||||
- Fix Release Message Error
|
|
||||||
|
|
||||||
## v0.7.7 (2024-05-04)
|
|
||||||
|
|
||||||
- Update Selector 2
|
|
||||||
|
|
||||||
## v0.7.6 (2024-05-04)
|
|
||||||
|
|
||||||
- Update Version
|
|
||||||
- Fix Proxies Select Error
|
|
||||||
|
|
||||||
## v0.7.5 (2024-05-03)
|
|
||||||
|
|
||||||
- Fix the problem that the proxy group is empty in global mode.
|
|
||||||
- Fix the problem that the proxy group is empty in global mode.
|
|
||||||
|
|
||||||
## v0.7.4 (2024-05-03)
|
|
||||||
|
|
||||||
- Add ProxyProvider2
|
|
||||||
|
|
||||||
## v0.7.3 (2024-05-03)
|
|
||||||
|
|
||||||
- Add ProxyProvider
|
|
||||||
- Update Version
|
|
||||||
- Update ProxyGroup Sort
|
|
||||||
- Fix Android quickStart VpnService some problems
|
|
||||||
|
|
||||||
## v0.7.1 (2024-05-01)
|
|
||||||
|
|
||||||
- Update version
|
|
||||||
- Set Android notification low importance
|
|
||||||
- Fix the issue that VpnService can't be closed correctly in special cases
|
|
||||||
- Fix the problem that TileService is not destroyed correctly in some cases
|
|
||||||
- Adjust tab animation defaults
|
|
||||||
- Add Telegram in README_zh_CN.md
|
|
||||||
- Add Telegram
|
|
||||||
|
|
||||||
## v0.7.0 (2024-04-30)
|
|
||||||
|
|
||||||
- update mobile_scanner
|
|
||||||
- Initial commit
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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,138 +1,109 @@
|
|||||||
<div align="center">
|
<div>
|
||||||
|
|
||||||
<img src="assets/images/icon.png" alt="FlClash" width="88">
|
[**简体中文**](README_zh_CN.md)
|
||||||
|
|
||||||
# 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>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
## FlClash
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="snapshots/preview-dark.png">
|
<p style="text-align: left;">
|
||||||
<img alt="FlClash dashboard on a MacBook and a phone" src="snapshots/preview.png" width="92%">
|
<img alt="stars" src="https://img.shields.io/github/stars/chen08209/FlClash?style=flat-square&logo=github"/>
|
||||||
</picture>
|
<img alt="downloads" src="https://img.shields.io/github/downloads/chen08209/FlClash/total"/>
|
||||||
|
<a href="LICENSE">
|
||||||
|
<img alt="license" src="https://img.shields.io/github/license/chen08209/FlClash"/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
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>
|
</p>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **One app for Android, Windows, macOS and Linux**, with x64 and ARM64 builds on the desktop.
|
✈️ Multi-platform: Android, Windows, macOS and Linux
|
||||||
- **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
|
💻 Adaptive multiple screen sizes, Multiple color themes available
|
||||||
|
|
||||||
Get the latest build from [GitHub Releases](https://github.com/chen08209/FlClash/releases/latest), or open the
|
💡 Based on Material You Design, [Surfboard](https://github.com/getsurfboard/surfboard)-like UI
|
||||||
[website](https://chen08209.github.io/FlClash#download), which picks the right file for your device.
|
|
||||||
|
|
||||||
| Platform | Packages | Notes |
|
✨ Support subscription link, Dark mode
|
||||||
| --- | --- | --- |
|
|
||||||
| Android | APK for `arm64-v8a`, `armeabi-v7a` and `x86_64` | Most phones use `arm64-v8a`. Also on the F-Droid repository below. |
|
|
||||||
| Windows 10 and later | Installer (`.exe`) or portable `.zip`, for x64 and ARM64 | Pick ARM64 on Snapdragon and other ARM laptops. |
|
|
||||||
| macOS 12 and later | DMG for Apple Silicon and Intel | Also on Homebrew. |
|
|
||||||
| Linux | `.deb`, `.rpm` and AppImage, for x64 and ARM64 | See the tray note below. |
|
|
||||||
|
|
||||||
<p>
|
## Contact
|
||||||
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" height="56"></a>
|
|
||||||
<a href="https://github.com/chen08209/FlClash/releases/latest"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" height="56"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
**Homebrew**
|
[Telegram](https://t.me/+G-veVtwBOl4wODc1)
|
||||||
|
|
||||||
```bash
|
## Build
|
||||||
brew tap chen08209/tap
|
|
||||||
brew install --cask flclash
|
|
||||||
```
|
|
||||||
|
|
||||||
**Linux tray icon**
|
1. Update submodules
|
||||||
|
```bash
|
||||||
|
git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
The `.deb` package installs its own dependencies. With the AppImage or the `.rpm`, install the AyatanaAppIndicator
|
2. Install `Flutter` and `Golang` environment
|
||||||
library so the tray icon can show:
|
|
||||||
|
|
||||||
```bash
|
3. Build Application
|
||||||
sudo apt-get install libayatana-appindicator3-1 # Debian and Ubuntu
|
|
||||||
sudo dnf install libayatana-appindicator-gtk3 # Fedora
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
- android
|
||||||
|
|
||||||
**Import a profile from a link.** Opening a link in this form imports the subscription into FlClash. The `clashmeta://`
|
1. Install `Android SDK` , `Android NDK`
|
||||||
and `flclash://` schemes work the same way.
|
|
||||||
|
|
||||||
```text
|
2. Set `ANDROID_NDK` environment variables
|
||||||
clash://install-config?url=<URL-encoded subscription link>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Automate on Android.** Tasker, MacroDroid and similar apps can start, stop or toggle the proxy by starting an
|
3. Run Build script
|
||||||
activity with one of these actions:
|
|
||||||
|
|
||||||
```text
|
```bash
|
||||||
com.follow.clash.action.START
|
dart .\setup.dart android
|
||||||
com.follow.clash.action.STOP
|
```
|
||||||
com.follow.clash.action.TOGGLE
|
|
||||||
```
|
|
||||||
|
|
||||||
From a computer, the same works over adb:
|
- windows
|
||||||
|
|
||||||
```bash
|
1. You need a windows client
|
||||||
adb shell am start -a com.follow.clash.action.TOGGLE
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build from source
|
2. Install `Gcc`,`Inno Setup`
|
||||||
|
|
||||||
You need [Flutter](https://docs.flutter.dev/get-started/install) 3.47 (release builds use 3.47.6),
|
3. Run build script
|
||||||
[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.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone --recursive https://github.com/chen08209/FlClash.git
|
dart .\setup.dart
|
||||||
cd FlClash
|
```
|
||||||
flutter pub get
|
|
||||||
dart setup.dart android # or windows, macos, linux
|
|
||||||
```
|
|
||||||
|
|
||||||
Packages land in `dist/`. The Go core and the Rust libraries are compiled as part of the Flutter build.
|
- linux
|
||||||
|
|
||||||
| Platform | Also needed |
|
1. You need a linux client
|
||||||
| --- | --- |
|
|
||||||
| 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`. |
|
|
||||||
|
|
||||||
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
|
||||||
|
```
|
||||||
|
|
||||||
Starring the repository is the easiest way to support the project. Questions and announcements go to the
|
- macOS
|
||||||
[Telegram channel](https://t.me/FlClash); bugs and feature requests go to
|
|
||||||
[GitHub Issues](https://github.com/chen08209/FlClash/issues).
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#chen08209/FlClash&Date">
|
1. You need a macOS client
|
||||||
<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>
|
|
||||||
|
|
||||||
## License
|
2. Run build script
|
||||||
|
|
||||||
FlClash is released under the [GPL-3.0 license](LICENSE).
|
```bash
|
||||||
|
dart .\setup.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Star
|
||||||
|
|
||||||
|
The easiest way to support developers is to click on the star (⭐) at the top of the page.
|
||||||
|
|
||||||
|
<p style="text-align: center;">
|
||||||
|
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
|
||||||
|
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
@@ -1,130 +1,106 @@
|
|||||||
<div align="center">
|
<div>
|
||||||
|
|
||||||
<img src="assets/images/icon.png" alt="FlClash" width="88">
|
[**English**](README.md)
|
||||||
|
|
||||||
# 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>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
## FlClash
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="snapshots/preview-dark.png">
|
<p style="text-align: left;">
|
||||||
<img alt="FlClash 在 MacBook 与手机上的仪表盘" src="snapshots/preview.png" width="92%">
|
<img alt="stars" src="https://img.shields.io/github/stars/chen08209/FlClash?style=flat-square&logo=github"/>
|
||||||
</picture>
|
<img alt="downloads" src="https://img.shields.io/github/downloads/chen08209/FlClash/total"/>
|
||||||
|
<a href="LICENSE">
|
||||||
|
<img alt="license" src="https://img.shields.io/github/license/chen08209/FlClash"/>
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 功能
|
基于ClashMeta的多平台代理客户端,简单易用,开源无广告。
|
||||||
|
|
||||||
- **一个应用覆盖 Android、Windows、macOS 和 Linux**,桌面端同时提供 x64 与 ARM64 版本。
|
on Desktop:
|
||||||
- **mihomo(Clash.Meta)内核**:规则分流、代理组、延迟测试、系统代理与 TUN 模式。
|
<p style="text-align: center;">
|
||||||
- **配置管理**:通过订阅链接或文件导入配置,内置编辑器,支持覆写脚本以及自定义规则、代理和代理组。
|
<img alt="desktop" src="snapshots/desktop.gif">
|
||||||
- **实时查看**连接、请求、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>
|
</p>
|
||||||
|
|
||||||
**Homebrew**
|
on Mobile:
|
||||||
|
<p style="text-align: center;">
|
||||||
|
<img alt="mobile" src="snapshots/mobile.gif">
|
||||||
|
</p>
|
||||||
|
|
||||||
```bash
|
## Features
|
||||||
brew tap chen08209/tap
|
|
||||||
brew install --cask flclash
|
|
||||||
```
|
|
||||||
|
|
||||||
**Linux 托盘图标**
|
✈️ 多平台: Android, Windows, macOS and Linux
|
||||||
|
|
||||||
`.deb` 安装包会自动安装所需依赖。使用 AppImage 或 `.rpm` 时,需要先安装 AyatanaAppIndicator 库,托盘图标才能显示:
|
💻 自适应多个屏幕尺寸,多种颜色主题可供选择
|
||||||
|
|
||||||
```bash
|
💡 基本 Material You 设计, 类[Surfboard](https://github.com/getsurfboard/surfboard)用户界面
|
||||||
sudo apt-get install libayatana-appindicator3-1 # Debian 与 Ubuntu
|
|
||||||
sudo dnf install libayatana-appindicator-gtk3 # Fedora
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用
|
✨ 支持一键导入订阅, 深色模式
|
||||||
|
|
||||||
**通过链接导入配置。** 打开下面格式的链接,即可把订阅导入 FlClash。`clashmeta://` 和 `flclash://` 开头的链接同样可用。
|
## Contact
|
||||||
|
|
||||||
```text
|
[Telegram](https://t.me/+G-veVtwBOl4wODc1)
|
||||||
clash://install-config?url=<经过 URL 编码的订阅链接>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Android 自动化。** Tasker、MacroDroid 等应用可以用下面的 Action 启动 Activity,从而启动、停止或切换代理:
|
## Build
|
||||||
|
|
||||||
```text
|
1. 更新 submodules
|
||||||
com.follow.clash.action.START
|
```bash
|
||||||
com.follow.clash.action.STOP
|
git submodule update --init --recursive
|
||||||
com.follow.clash.action.TOGGLE
|
```
|
||||||
```
|
|
||||||
|
|
||||||
在电脑上也可以通过 adb 触发:
|
2. 安装 `Flutter` 以及 `Golang` 环境
|
||||||
|
|
||||||
```bash
|
3. 构建应用
|
||||||
adb shell am start -a com.follow.clash.action.TOGGLE
|
|
||||||
```
|
|
||||||
|
|
||||||
## 从源码构建
|
- android
|
||||||
|
|
||||||
需要 [Flutter](https://docs.flutter.dev/get-started/install) 3.47(正式版构建使用 3.47.6)、[Go](https://go.dev/dl/) 1.27,
|
1. 安装 `Android SDK` , `Android NDK`
|
||||||
以及通过 rustup 安装的 [Rust](https://rustup.rs/)。桌面端需要在对应系统上构建,Android 在任意系统上都能构建。
|
|
||||||
|
|
||||||
```bash
|
2. 设置 `ANDROID_NDK` 环境变量
|
||||||
git clone --recursive https://github.com/chen08209/FlClash.git
|
|
||||||
cd FlClash
|
|
||||||
flutter pub get
|
|
||||||
dart setup.dart android # 或 windows、macos、linux
|
|
||||||
```
|
|
||||||
|
|
||||||
安装包输出到 `dist/`。Go 内核和 Rust 库会在 Flutter 构建过程中一并编译。
|
3. 运行构建脚本
|
||||||
|
|
||||||
| 平台 | 额外依赖 |
|
```bash
|
||||||
| --- | --- |
|
dart .\setup.dart android
|
||||||
| 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`。 |
|
|
||||||
|
|
||||||
其余选项可以运行 `dart setup.dart --help` 查看,例如用 `--targets` 只构建部分安装包格式。
|
- windows
|
||||||
|
|
||||||
## 支持
|
1. 你需要一个windows客户端
|
||||||
|
|
||||||
给仓库点一个 Star 是支持项目最简单的方式。问题讨论和更新通知请关注 [Telegram 频道](https://t.me/FlClash),
|
2. 安装 `Gcc`,`Inno Setup`
|
||||||
Bug 与功能建议请提交到 [GitHub Issues](https://github.com/chen08209/FlClash/issues)。
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#chen08209/FlClash&Date">
|
3. 运行构建脚本
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date&theme=dark">
|
|
||||||
<img alt="Star history" src="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date" width="640">
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## 许可证
|
```bash
|
||||||
|
dart .\setup.dart
|
||||||
|
```
|
||||||
|
|
||||||
FlClash 以 [GPL-3.0 协议](LICENSE)发布。
|
- linux
|
||||||
|
|
||||||
|
1. 你需要一个linux客户端
|
||||||
|
|
||||||
|
2. 运行构建脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart .\setup.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
- macOS
|
||||||
|
|
||||||
|
1. 你需要一个macOS客户端
|
||||||
|
|
||||||
|
2. 运行构建脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart .\setup.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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>
|
||||||
@@ -1,16 +1,29 @@
|
|||||||
include: lint_options.yaml
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
|
# check for errors, warnings, and lints.
|
||||||
|
#
|
||||||
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||||
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||||
|
# invoked from the command line by running `flutter analyze`.
|
||||||
|
|
||||||
analyzer:
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
exclude:
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
- build/**
|
include: package:flutter_lints/flutter.yaml
|
||||||
- lib/l10n/intl/**
|
|
||||||
- lib/**/generated/**
|
linter:
|
||||||
- plugins/**
|
# The lint rules applied to this project can be customized in the
|
||||||
- android/**
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
- ios/**
|
# included above or to enable additional rules. A list of all available lints
|
||||||
- web/**
|
# and their documentation is published at
|
||||||
- windows/**
|
# https://dart-lang.github.io/linter/lints/index.html.
|
||||||
- macos/**
|
#
|
||||||
- linux/**
|
# Instead of disabling a lint rule for the entire project in the
|
||||||
errors:
|
# section below, it can also be suppressed for a single line of code
|
||||||
invalid_annotation_target: ignore
|
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||||
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
|
# producing the lint.
|
||||||
|
rules:
|
||||||
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import com.android.build.gradle.tasks.MergeSourceSetFolders
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id "com.android.application"
|
||||||
|
id "kotlin-android"
|
||||||
|
id "dev.flutter.flutter-gradle-plugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
def localProperties = new Properties()
|
||||||
|
def localPropertiesFile = rootProject.file('local.properties')
|
||||||
|
if (localPropertiesFile.exists()) {
|
||||||
|
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||||
|
localProperties.load(reader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||||
|
if (flutterVersionCode == null) {
|
||||||
|
flutterVersionCode = '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||||
|
if (flutterVersionName == null) {
|
||||||
|
flutterVersionName = '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
def defStoreFile = file("keystore.jks")
|
||||||
|
def defStorePassword = localProperties.getProperty('storePassword')
|
||||||
|
def defKeyAlias = localProperties.getProperty('keyAlias')
|
||||||
|
def defKeyPassword = localProperties.getProperty('keyPassword')
|
||||||
|
def isRelease = defStoreFile.exists() && defStorePassword != null && defKeyAlias != null && defKeyPassword != null
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.follow.clash"
|
||||||
|
compileSdkVersion 34
|
||||||
|
ndkVersion "25.1.8937393"
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '1.8'
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
main.java.srcDirs += 'src/main/kotlin'
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
if (isRelease){
|
||||||
|
release {
|
||||||
|
storeFile defStoreFile
|
||||||
|
storePassword defStorePassword
|
||||||
|
keyAlias defKeyAlias
|
||||||
|
keyPassword defKeyPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.follow.clash"
|
||||||
|
minSdkVersion 21
|
||||||
|
targetSdkVersion 34
|
||||||
|
versionCode flutterVersionCode.toInteger()
|
||||||
|
versionName flutterVersionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
minifyEnabled false
|
||||||
|
applicationIdSuffix '.debug'
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
minifyEnabled true
|
||||||
|
if(isRelease){
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}else{
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
}
|
||||||
|
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('copyNativeLibs', Copy) {
|
||||||
|
delete('src/main/jniLibs')
|
||||||
|
from('../../libclash/android')
|
||||||
|
into('src/main/jniLibs')
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(MergeSourceSetFolders).configureEach {
|
||||||
|
dependsOn copyNativeLibs
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source '../..'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.core:core-splashscreen:1.0.1'
|
||||||
|
implementation 'com.google.code.gson:gson:2.10'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
assembleDebug.dependsOn copyNativeLibs
|
||||||
|
|
||||||
|
assembleRelease.dependsOn copyNativeLibs
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
import java.util.Properties
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
|
||||||
}
|
|
||||||
|
|
||||||
val localProperties = Properties().apply {
|
|
||||||
val localPropertiesFile = rootProject.file("local.properties")
|
|
||||||
if (localPropertiesFile.exists()) {
|
|
||||||
localPropertiesFile.inputStream().use { load(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "com.follow.clash"
|
|
||||||
minSdk = flutter.minSdkVersion
|
|
||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
|
||||||
versionCode = flutter.versionCode
|
|
||||||
versionName = flutter.versionName
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
if (hasReleaseSigning) {
|
|
||||||
create("release") {
|
|
||||||
storeFile = releaseStoreFile
|
|
||||||
storePassword = releaseStorePassword
|
|
||||||
keyAlias = releaseKeyAlias
|
|
||||||
keyPassword = releaseKeyPassword
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
packaging {
|
|
||||||
jniLibs {
|
|
||||||
useLegacyPackaging = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
debug {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
applicationIdSuffix = ".dev"
|
|
||||||
}
|
|
||||||
|
|
||||||
release {
|
|
||||||
isMinifyEnabled = true
|
|
||||||
isShrinkResources = true
|
|
||||||
if (hasReleaseSigning) {
|
|
||||||
signingConfig = signingConfigs.getByName("release")
|
|
||||||
} else {
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
applicationIdSuffix = ".dev"
|
|
||||||
}
|
|
||||||
|
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"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 {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_17)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flutter {
|
|
||||||
source = "../.."
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
|
|
||||||
-keep class com.follow.clash.models.** { *; }
|
-keep class com.follow.clash.models.**{ *; }
|
||||||
|
|
||||||
-keep class com.follow.clash.service.models.** { *; }
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
<application
|
the Flutter tool needs it to communicate with the running application
|
||||||
android:icon="@mipmap/ic_launcher"
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
android:label="FlClash Debug"
|
-->
|
||||||
tools:replace="android:label">
|
<application android:label="FlClash Debug" tools:replace="android:label">
|
||||||
<service
|
<service
|
||||||
android:name=".TileService"
|
android:name=".services.FlClashTileService"
|
||||||
android:label="FlClash Debug"
|
android:label="FlClash Debug"
|
||||||
tools:replace="android:label" />
|
tools:replace="android:label">
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,61 +1,49 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.software.leanback"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.camera"
|
android:name="android.hardware.touchscreen"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.software.leanback"
|
android:name="android.hardware.camera"
|
||||||
android:required="false" />
|
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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".FlClashApplication"
|
android:name="${applicationName}"
|
||||||
android:banner="@mipmap/ic_banner"
|
|
||||||
android:hardwareAccelerated="true"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name">
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:label="FlClash">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name="com.follow.clash.MainActivity"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||||
@@ -66,38 +54,27 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="clash" />
|
<data android:scheme="clash"/>
|
||||||
<data android:scheme="clashmeta" />
|
<data android:scheme="clashmeta"/>
|
||||||
<data android:scheme="flclash" />
|
<data android:scheme="flclash"/>
|
||||||
|
|
||||||
<data android:host="install-config" />
|
<data android:host="install-config"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- <meta-data-->
|
||||||
|
<!-- android:name="io.flutter.embedding.android.EnableImpeller"-->
|
||||||
|
<!-- android:value="true" />-->
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".QuickActionActivity"
|
android:name=".TempActivity"
|
||||||
android:excludeFromRecents="true"
|
android:theme="@style/TransparentTheme" />
|
||||||
android:exported="true"
|
|
||||||
android:theme="@style/TransparentTheme">
|
|
||||||
<intent-filter>
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<action android:name="${applicationId}.action.START" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<action android:name="${applicationId}.action.STOP" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<action android:name="${applicationId}.action.TOGGLE" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".TileService"
|
android:name=".services.FlClashTileService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:icon="@drawable/ic"
|
android:icon="@drawable/tile_icon"
|
||||||
android:label="@string/app_name"
|
android:label="FlClash"
|
||||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
@@ -106,20 +83,18 @@
|
|||||||
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
||||||
android:value="true" />
|
android:value="true" />
|
||||||
</service>
|
</service>
|
||||||
|
<service
|
||||||
<receiver
|
android:name=".services.FlClashVpnService"
|
||||||
android:name=".ServiceBroadcastReceiver"
|
android:exported="false"
|
||||||
android:enabled="true"
|
android:foregroundServiceType="specialUse"
|
||||||
android:exported="true"
|
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||||
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="${applicationId}.intent.action.VPN_START_REQUESTED" />
|
<action android:name="android.net.VpnService" />
|
||||||
<action android:name="${applicationId}.intent.action.VPN_REVOKED" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</service>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,105 +0,0 @@
|
|||||||
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.withContext
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
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 lastUpdateTime = getPackageInfo(packageName, 0).lastUpdateTime
|
|
||||||
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
|
|
||||||
if (iconFile.exists() && !isExpired(iconFile)) {
|
|
||||||
return@withContext iconFile.absolutePath
|
|
||||||
}
|
|
||||||
iconDir.listFiles { f -> f.name.startsWith("${packageName}_") }?.forEach(File::delete)
|
|
||||||
|
|
||||||
val icon = getApplicationIcon(packageName)
|
|
||||||
saveDrawableToFile(icon, iconFile)
|
|
||||||
iconFile.absolutePath
|
|
||||||
} catch (_: Exception) {
|
|
||||||
val defaultIconFile = File(iconDir, "default_icon.webp")
|
|
||||||
if (!defaultIconFile.exists()) {
|
|
||||||
saveDrawableToFile(defaultActivityIcon, defaultIconFile)
|
|
||||||
}
|
|
||||||
defaultIconFile.absolutePath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun saveDrawableToFile(drawable: Drawable, file: File) {
|
|
||||||
val bitmap = withContext(Dispatchers.Default) {
|
|
||||||
drawable.toBitmap(width = 128, height = 128)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
@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)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!bitmap.isRecycled) bitmap.recycle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isExpired(file: File): Boolean {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val age = now - file.lastModified()
|
|
||||||
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
|
|
||||||
return plugins.get(T::class.java) as T?
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MethodChannel.invokeMethodOnMainThread(method: String, arguments: Any? = null) {
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
|
||||||
invokeMethod(method, arguments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package com.follow.clash
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
import com.follow.clash.common.GlobalState
|
|
||||||
|
|
||||||
class FlClashApplication : Application() {
|
|
||||||
override fun attachBaseContext(base: Context?) {
|
|
||||||
super.attachBaseContext(base)
|
|
||||||
GlobalState.init(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.follow.clash
|
||||||
|
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import com.follow.clash.plugins.TilePlugin
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
enum class RunState {
|
||||||
|
START,
|
||||||
|
PENDING,
|
||||||
|
STOP
|
||||||
|
}
|
||||||
|
|
||||||
|
class GlobalState {
|
||||||
|
companion object {
|
||||||
|
val runState: MutableLiveData<RunState> = MutableLiveData<RunState>(RunState.STOP)
|
||||||
|
var runTime: Date? = null
|
||||||
|
var flutterEngine: FlutterEngine? = null
|
||||||
|
fun getCurrentTilePlugin(): TilePlugin? =
|
||||||
|
flutterEngine?.plugins?.get(TilePlugin::class.java) as TilePlugin?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
package com.follow.clash
|
package com.follow.clash
|
||||||
|
|
||||||
|
|
||||||
import com.follow.clash.plugins.AppPlugin
|
import com.follow.clash.plugins.AppPlugin
|
||||||
import com.follow.clash.plugins.BatteryPlugin
|
import com.follow.clash.plugins.ProxyPlugin
|
||||||
import com.follow.clash.plugins.ServicePlugin
|
|
||||||
import com.follow.clash.plugins.TilePlugin
|
import com.follow.clash.plugins.TilePlugin
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
GlobalState.flutterEngine?.destroy()
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
flutterEngine.plugins.add(AppPlugin())
|
flutterEngine.plugins.add(AppPlugin())
|
||||||
flutterEngine.plugins.add(BatteryPlugin())
|
flutterEngine.plugins.add(ProxyPlugin())
|
||||||
flutterEngine.plugins.add(ServicePlugin())
|
|
||||||
flutterEngine.plugins.add(TilePlugin())
|
flutterEngine.plugins.add(TilePlugin())
|
||||||
ServiceState.attachFlutterEngine(flutterEngine)
|
GlobalState.flutterEngine = flutterEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
flutterEngine?.let(ServiceState::detachFlutterEngine)
|
GlobalState.flutterEngine = null
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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,47 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package com.follow.clash
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.VpnService
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.follow.clash.common.GlobalState
|
|
||||||
import com.follow.clash.models.SharedState
|
|
||||||
import com.follow.clash.plugins.AppPlugin
|
|
||||||
import com.follow.clash.plugins.ServicePlugin
|
|
||||||
import com.follow.clash.plugins.TilePlugin
|
|
||||||
import com.follow.clash.service.ServiceConfig
|
|
||||||
import com.follow.clash.service.models.NotificationParams
|
|
||||||
import com.follow.clash.service.models.VpnOptions
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
|
|
||||||
internal data class ServiceRuntime(
|
|
||||||
val vpn: Boolean,
|
|
||||||
val startedAtMillis: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The machine owns the arbitration and the transitions; this is only the part that cannot run on a
|
|
||||||
* plain JVM, so unit tests can drive the state machine with an in-memory implementation.
|
|
||||||
*/
|
|
||||||
internal interface ServiceStateHost {
|
|
||||||
val scope: CoroutineScope
|
|
||||||
val runtime: ServiceRuntime?
|
|
||||||
val homeDirPath: String
|
|
||||||
val sdkInt: Int
|
|
||||||
|
|
||||||
fun log(message: String)
|
|
||||||
|
|
||||||
fun showToast(message: String)
|
|
||||||
|
|
||||||
fun updateNotificationParams(params: NotificationParams)
|
|
||||||
|
|
||||||
fun loadSharedState(): SharedState
|
|
||||||
|
|
||||||
fun isVpnPermissionGranted(): Boolean
|
|
||||||
|
|
||||||
fun isLocalNetworkPermissionGranted(): Boolean
|
|
||||||
|
|
||||||
fun tile(): TileGateway?
|
|
||||||
|
|
||||||
fun app(): AppGateway?
|
|
||||||
|
|
||||||
fun notifyStopped()
|
|
||||||
|
|
||||||
suspend fun quickSetup(initParams: String, setupParams: String): Result<String>
|
|
||||||
|
|
||||||
suspend fun startService(options: VpnOptions): Boolean
|
|
||||||
|
|
||||||
suspend fun stopService()
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun interface TileGateway {
|
|
||||||
fun handleStart()
|
|
||||||
}
|
|
||||||
|
|
||||||
internal interface AppGateway {
|
|
||||||
fun requestNotificationPermission(callback: (Boolean) -> Unit)
|
|
||||||
|
|
||||||
fun requestLocalNetworkPermission(callback: (Boolean) -> Unit)
|
|
||||||
|
|
||||||
fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit)
|
|
||||||
|
|
||||||
fun cancelVpnPreparation(callback: (Boolean) -> Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal object AndroidServiceStateHost : ServiceStateHost {
|
|
||||||
@Volatile
|
|
||||||
private var flutterEngine: FlutterEngine? = null
|
|
||||||
|
|
||||||
override val scope: CoroutineScope
|
|
||||||
get() = GlobalState
|
|
||||||
|
|
||||||
override val runtime: ServiceRuntime?
|
|
||||||
get() = ServiceController.runtime
|
|
||||||
|
|
||||||
override val homeDirPath: String
|
|
||||||
get() = GlobalState.application.filesDir.path
|
|
||||||
|
|
||||||
override val sdkInt: Int
|
|
||||||
get() = android.os.Build.VERSION.SDK_INT
|
|
||||||
|
|
||||||
fun attachFlutterEngine(engine: FlutterEngine) {
|
|
||||||
flutterEngine = engine
|
|
||||||
}
|
|
||||||
|
|
||||||
fun detachFlutterEngine(engine: FlutterEngine) {
|
|
||||||
if (flutterEngine === engine) {
|
|
||||||
flutterEngine = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun log(message: String) = GlobalState.log(message)
|
|
||||||
|
|
||||||
override fun showToast(message: String) = GlobalState.application.showToast(message)
|
|
||||||
|
|
||||||
override fun updateNotificationParams(params: NotificationParams) =
|
|
||||||
ServiceConfig.updateNotificationParams(params)
|
|
||||||
|
|
||||||
override fun loadSharedState(): SharedState = GlobalState.application.sharedState
|
|
||||||
|
|
||||||
override fun isVpnPermissionGranted(): Boolean =
|
|
||||||
VpnService.prepare(GlobalState.application) == null
|
|
||||||
|
|
||||||
override fun isLocalNetworkPermissionGranted(): Boolean =
|
|
||||||
sdkInt < Build.VERSION_CODES.CINNAMON_BUN ||
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
GlobalState.application,
|
|
||||||
Manifest.permission.ACCESS_LOCAL_NETWORK,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
override fun tile(): TileGateway? = flutterEngine?.plugin<TilePlugin>()?.let { plugin ->
|
|
||||||
TileGateway(plugin::handleStart)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun app(): AppGateway? = flutterEngine?.plugin<AppPlugin>()?.let { plugin ->
|
|
||||||
object : AppGateway {
|
|
||||||
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
|
|
||||||
plugin.requestNotificationPermission(callback)
|
|
||||||
|
|
||||||
override fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) =
|
|
||||||
plugin.requestLocalNetworkPermission(callback)
|
|
||||||
|
|
||||||
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) =
|
|
||||||
plugin.prepareVpn(enable, callback)
|
|
||||||
|
|
||||||
override fun cancelVpnPreparation(callback: (Boolean) -> Unit) =
|
|
||||||
plugin.cancelVpnPreparation(callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun notifyStopped() {
|
|
||||||
flutterEngine?.plugin<ServicePlugin>()?.notifyStopped()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> =
|
|
||||||
ServiceController.quickSetup(initParams, setupParams)
|
|
||||||
|
|
||||||
override suspend fun startService(options: VpnOptions): Boolean =
|
|
||||||
ServiceController.start(options)
|
|
||||||
|
|
||||||
override suspend fun stopService() = ServiceController.stop()
|
|
||||||
}
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
package com.follow.clash
|
|
||||||
|
|
||||||
import com.follow.clash.common.RunIntentArbiter
|
|
||||||
import com.follow.clash.models.SharedState
|
|
||||||
import com.follow.clash.service.models.NotificationParams
|
|
||||||
import com.follow.clash.service.models.VpnOptions
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.Deferred
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
enum class RunState {
|
|
||||||
STARTED,
|
|
||||||
STARTING,
|
|
||||||
STOPPING,
|
|
||||||
STOPPED,
|
|
||||||
}
|
|
||||||
|
|
||||||
internal typealias RunRequest = RunIntentArbiter.Token
|
|
||||||
|
|
||||||
internal const val MISSING_CONFIG_MESSAGE = "No configuration found."
|
|
||||||
internal const val INVALID_CONFIG_MESSAGE = "Invalid configuration."
|
|
||||||
internal const val VPN_PERMISSION_MESSAGE = "VPN permission required."
|
|
||||||
internal const val START_FAILED_MESSAGE = "Failed to start service."
|
|
||||||
|
|
||||||
private enum class StartOutcome {
|
|
||||||
STARTED,
|
|
||||||
FAILED,
|
|
||||||
SUPERSEDED,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callers request a transition; the newest request always wins. No step assigns [runState]:
|
|
||||||
* [publish] derives it from the latest intent, the transition in flight and what the host really
|
|
||||||
* runs, so no path can leave it claiming a service that is not there.
|
|
||||||
*/
|
|
||||||
internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
|
||||||
private val transitionLock = Mutex()
|
|
||||||
private val startPreparationLock = Mutex()
|
|
||||||
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
|
|
||||||
private val arbiter = RunIntentArbiter()
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var sharedState = SharedState()
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var pendingVpnPreparation: (() -> Unit)? = null
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var transition: RunState? = null
|
|
||||||
|
|
||||||
val runState = mutableRunState.asStateFlow()
|
|
||||||
|
|
||||||
val isRunRequested: Boolean
|
|
||||||
get() = arbiter.isRunningRequested
|
|
||||||
|
|
||||||
suspend fun handleToggleAction() {
|
|
||||||
if (isRunRequested) {
|
|
||||||
handleStopAction()
|
|
||||||
} else {
|
|
||||||
handleStartAction()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun awaitRunTime(): Long = transitionLock.withLock {
|
|
||||||
host.runtime?.startedAtMillis?.takeIf { isRunRequested } ?: 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
fun captureRequestToken(): RunRequest = arbiter.current()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settles the state after the bound service was lost. [token] is the request that was current
|
|
||||||
* when the loss was observed, so a start that raced ahead of this callback keeps its intent.
|
|
||||||
*/
|
|
||||||
suspend fun handleServiceLost(token: RunRequest) {
|
|
||||||
val dropped = transitionLock.withLock {
|
|
||||||
if (host.runtime != null) {
|
|
||||||
return@withLock false
|
|
||||||
}
|
|
||||||
arbiter.resetToStopped(token).also { publish() }
|
|
||||||
}
|
|
||||||
if (dropped) {
|
|
||||||
host.notifyStopped()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun handleStartAction() {
|
|
||||||
if (isRunRequested) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val tile = host.tile()
|
|
||||||
if (tile != null) {
|
|
||||||
tile.handleStart()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
startFromPreferences()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun handleStopAction() {
|
|
||||||
if (!isRunRequested) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
host.showToast(sharedState.stopTip)
|
|
||||||
requestStop().await()
|
|
||||||
host.notifyStopped()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun handleVpnRevokeAction() {
|
|
||||||
if (host.runtime?.vpn != true) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleStopAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requestStart(): Deferred<Boolean> {
|
|
||||||
if (isStartedAs(sharedState.vpnOptions)) {
|
|
||||||
return CompletableDeferred(true)
|
|
||||||
}
|
|
||||||
val outcome = launchStart(createRequest(running = true))
|
|
||||||
return host.scope.async { outcome.await() == StartOutcome.STARTED }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requestStop(): Deferred<Boolean> {
|
|
||||||
val request = createRequest(running = false)
|
|
||||||
return host.scope.async { stop(request) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun syncSharedState(state: SharedState) {
|
|
||||||
sharedState = state
|
|
||||||
applySharedState()
|
|
||||||
}
|
|
||||||
|
|
||||||
// No new request for a settled run: one refused at a prompt would take the service down.
|
|
||||||
private fun isStartedAs(options: VpnOptions?): Boolean =
|
|
||||||
runState.value == RunState.STARTED && host.runtime?.vpn == options?.enable
|
|
||||||
|
|
||||||
// The intent is claimed before the Core is set up, so a second action that lands meanwhile
|
|
||||||
// toggles this start off instead of racing a second setup against it.
|
|
||||||
private suspend fun startFromPreferences() {
|
|
||||||
val request = createRequest(running = true)
|
|
||||||
sharedState = host.loadSharedState()
|
|
||||||
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
|
|
||||||
host.showToast(MISSING_CONFIG_MESSAGE)
|
|
||||||
abandon(request)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!setupCore()) {
|
|
||||||
abandon(request)
|
|
||||||
} else if (launchStart(request).await() == StartOutcome.FAILED) {
|
|
||||||
host.showToast(START_FAILED_MESSAGE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun launchStart(request: RunRequest): Deferred<StartOutcome> {
|
|
||||||
val result = CompletableDeferred<StartOutcome>()
|
|
||||||
val launch: (Boolean) -> Unit = { permitted ->
|
|
||||||
host.scope.launch {
|
|
||||||
result.complete(if (permitted) start(request) else abandon(request))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val app = host.app()
|
|
||||||
if (app != null) {
|
|
||||||
app.requestNotificationPermission { permitted ->
|
|
||||||
if (permitted) {
|
|
||||||
app.requestLocalNetworkPermission { launch(true) }
|
|
||||||
} else {
|
|
||||||
launch(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
launch(true)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applySharedState() {
|
|
||||||
host.updateNotificationParams(notificationParams(sharedState))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun setupCore(): Boolean {
|
|
||||||
applySharedState()
|
|
||||||
host.showToast(sharedState.startTip)
|
|
||||||
return host.quickSetup(
|
|
||||||
initParams(host.homeDirPath, host.sdkInt),
|
|
||||||
Gson().toJson(sharedState.setupParams),
|
|
||||||
).fold(
|
|
||||||
onSuccess = { message ->
|
|
||||||
if (message.isEmpty()) {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
host.log("Unable to set up core: $message")
|
|
||||||
showConfigError(message)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onFailure = { error ->
|
|
||||||
host.log("Unable to set up core: $error")
|
|
||||||
showConfigError(error.message)
|
|
||||||
false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showConfigError(message: String?) {
|
|
||||||
host.showToast(message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun start(request: RunRequest): StartOutcome {
|
|
||||||
val started = try {
|
|
||||||
startPreparationLock.withLock { runStart(request) }
|
|
||||||
} catch (error: CancellationException) {
|
|
||||||
throw error
|
|
||||||
} catch (error: Exception) {
|
|
||||||
host.log("Unable to process service start request: $error")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
return if (started) StartOutcome.STARTED else abandon(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun abandon(request: RunRequest): StartOutcome {
|
|
||||||
val dropped = arbiter.resetToStopped(request)
|
|
||||||
reconcileStopped()
|
|
||||||
if (!dropped) {
|
|
||||||
return StartOutcome.SUPERSEDED
|
|
||||||
}
|
|
||||||
host.notifyStopped()
|
|
||||||
return StartOutcome.FAILED
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runStart(request: RunRequest): Boolean {
|
|
||||||
if (!isCurrent(request)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val options = sharedState.vpnOptions ?: return false
|
|
||||||
if (!prepareVpn(options)) {
|
|
||||||
if (host.app() == null && isCurrent(request)) {
|
|
||||||
host.showToast(VPN_PERMISSION_MESSAGE)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return transitionLock.withLock {
|
|
||||||
when {
|
|
||||||
!isCurrent(request) -> false
|
|
||||||
host.runtime?.vpn == options.enable -> true
|
|
||||||
else -> transitioning(RunState.STARTING) {
|
|
||||||
host.startService(withLocalNetworkFallback(options)) && isCurrent(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// system/mixed hand TCP to the kernel via the tun subnet, which Android 17 gates as local network.
|
|
||||||
private fun withLocalNetworkFallback(options: VpnOptions): VpnOptions {
|
|
||||||
if (!options.enable || options.stack !in KERNEL_TCP_STACKS ||
|
|
||||||
host.isLocalNetworkPermissionGranted()
|
|
||||||
) {
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
host.showToast(sharedState.localNetworkTip)
|
|
||||||
return options.copy(stack = FALLBACK_STACK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flutter's startListener and the native quickSetup both open the Core's listeners before any
|
|
||||||
// service exists to answer for them, and Flutter may be gone before it hears of the failure.
|
|
||||||
private suspend fun reconcileStopped() = transitionLock.withLock {
|
|
||||||
when {
|
|
||||||
isRunRequested -> publish()
|
|
||||||
host.runtime != null -> stopLocked()
|
|
||||||
else -> {
|
|
||||||
stopService()
|
|
||||||
publish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
|
|
||||||
if (!isCurrent(request)) {
|
|
||||||
return@withLock false
|
|
||||||
}
|
|
||||||
abandonVpnPreparation()
|
|
||||||
if (host.runtime != null) {
|
|
||||||
stopLocked()
|
|
||||||
}
|
|
||||||
isCurrent(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun stopLocked() = transitioning(RunState.STOPPING) { stopService() }
|
|
||||||
|
|
||||||
private suspend fun stopService() {
|
|
||||||
try {
|
|
||||||
host.stopService()
|
|
||||||
} catch (error: CancellationException) {
|
|
||||||
throw error
|
|
||||||
} catch (error: Exception) {
|
|
||||||
host.log("Unable to stop service: $error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <T> transitioning(state: RunState, block: () -> T): T {
|
|
||||||
transition = state
|
|
||||||
publish()
|
|
||||||
try {
|
|
||||||
return block()
|
|
||||||
} finally {
|
|
||||||
transition = null
|
|
||||||
publish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun publish() {
|
|
||||||
mutableRunState.value = transition ?: when {
|
|
||||||
host.runtime == null -> RunState.STOPPED
|
|
||||||
isRunRequested -> RunState.STARTED
|
|
||||||
else -> RunState.STOPPING
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun prepareVpn(options: VpnOptions): Boolean {
|
|
||||||
val app = host.app()
|
|
||||||
?: return !options.enable || host.isVpnPermissionGranted()
|
|
||||||
return suspendCancellableCoroutine { continuation ->
|
|
||||||
val callback: (Boolean) -> Unit = { granted ->
|
|
||||||
pendingVpnPreparation = null
|
|
||||||
if (continuation.isActive) {
|
|
||||||
continuation.resume(granted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pendingVpnPreparation = {
|
|
||||||
app.cancelVpnPreparation(callback)
|
|
||||||
callback(false)
|
|
||||||
}
|
|
||||||
continuation.invokeOnCancellation {
|
|
||||||
pendingVpnPreparation = null
|
|
||||||
app.cancelVpnPreparation(callback)
|
|
||||||
}
|
|
||||||
app.prepareVpn(options.enable, callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun abandonVpnPreparation() {
|
|
||||||
val abandon = pendingVpnPreparation ?: return
|
|
||||||
pendingVpnPreparation = null
|
|
||||||
abandon()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createRequest(running: Boolean): RunRequest =
|
|
||||||
arbiter.request(running).also { publish() }
|
|
||||||
|
|
||||||
private fun isCurrent(request: RunRequest): Boolean = arbiter.isCurrent(request)
|
|
||||||
|
|
||||||
internal companion object {
|
|
||||||
val KERNEL_TCP_STACKS = setOf("system", "mixed")
|
|
||||||
const val FALLBACK_STACK = "gvisor"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Core init payload. The key spelling is a cross-language contract with the Go wrapper,
|
|
||||||
* not an implementation detail.
|
|
||||||
*/
|
|
||||||
fun initParams(homeDirPath: String, sdkInt: Int): String = Gson().toJson(
|
|
||||||
mapOf(
|
|
||||||
"home-dir" to homeDirPath,
|
|
||||||
"version" to sdkInt,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun notificationParams(state: SharedState): NotificationParams = NotificationParams(
|
|
||||||
title = state.currentProfileName,
|
|
||||||
stopText = state.stopText,
|
|
||||||
onlyStatisticsProxy = state.onlyStatisticsProxy,
|
|
||||||
showStopAction = state.showStopAction,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.follow.clash
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Bundle
|
||||||
|
|
||||||
|
class TempActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
finishAndRemoveTask()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package com.follow.clash
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Build
|
|
||||||
import android.service.quicksettings.Tile
|
|
||||||
import com.follow.clash.common.QuickAction
|
|
||||||
import com.follow.clash.common.quickIntent
|
|
||||||
import com.follow.clash.common.toPendingIntent
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class TileService : android.service.quicksettings.TileService() {
|
|
||||||
private var scope: CoroutineScope? = null
|
|
||||||
|
|
||||||
override fun onStartListening() {
|
|
||||||
super.onStartListening()
|
|
||||||
scope?.cancel()
|
|
||||||
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 openQuickAction() {
|
|
||||||
val intent = QuickAction.TOGGLE.quickIntent
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
startActivityAndCollapse(intent.toPendingIntent)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
startActivityAndCollapse(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.follow.clash.extensions
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.system.OsConstants.IPPROTO_TCP
|
||||||
|
import android.system.OsConstants.IPPROTO_UDP
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import com.follow.clash.models.Metadata
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun Drawable.getBase64(): String {
|
||||||
|
val drawable = this
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val bitmap = drawable.toBitmap()
|
||||||
|
val byteArrayOutputStream = ByteArrayOutputStream()
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
|
||||||
|
Base64.encodeToString(byteArrayOutputStream.toByteArray(), Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Metadata.getProtocol(): Int? {
|
||||||
|
if (network.startsWith("tcp")) return IPPROTO_TCP
|
||||||
|
if (network.startsWith("udp")) return IPPROTO_UDP
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.follow.clash.models
|
||||||
|
|
||||||
|
enum class AccessControlMode {
|
||||||
|
acceptSelected,
|
||||||
|
rejectSelected,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AccessControl(
|
||||||
|
val mode: AccessControlMode,
|
||||||
|
val acceptList: List<String>,
|
||||||
|
val rejectList: List<String>,
|
||||||
|
)
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.follow.clash.models
|
|
||||||
|
|
||||||
data class InstalledPackage(
|
|
||||||
val packageName: String,
|
|
||||||
val label: String,
|
|
||||||
val system: Boolean,
|
|
||||||
val internet: Boolean,
|
|
||||||
val lastUpdateTime: Long,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.follow.clash.models
|
||||||
|
|
||||||
|
data class Metadata(
|
||||||
|
val network: String,
|
||||||
|
val sourceIP: String,
|
||||||
|
val sourcePort: Int,
|
||||||
|
val destinationIP: String,
|
||||||
|
val destinationPort: Int,
|
||||||
|
val remoteDestination: String,
|
||||||
|
val host: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.follow.clash.models
|
||||||
|
|
||||||
|
data class Package(
|
||||||
|
val packageName: String,
|
||||||
|
val label: String,
|
||||||
|
val isSystem:Boolean
|
||||||
|
)
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package com.follow.clash.models
|
|
||||||
|
|
||||||
import com.follow.clash.service.models.VpnOptions
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
data class SharedState(
|
|
||||||
val startTip: String = "Starting VPN...",
|
|
||||||
val stopTip: String = "Stopping VPN...",
|
|
||||||
val localNetworkTip: String =
|
|
||||||
"Local network permission denied: using the gvisor stack, LAN is unreachable.",
|
|
||||||
val 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,
|
|
||||||
)
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package com.follow.clash.packages
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The prefixes in [CHINA_PACKAGE_REGEX] are matched *without* a trailing dot
|
|
||||||
* boundary, and that is deliberate: `com.qihoo` has to reach `com.qihoo360.*`
|
|
||||||
* and `com.ali` has to reach `com.aliyun.*` and `com.alimama.*`. Requiring a
|
|
||||||
* separator would turn those into misses.
|
|
||||||
*
|
|
||||||
* The price is that unrelated names starting with the same letters match too.
|
|
||||||
* [SKIPPED_PREFIXES] is where those come back out, and it *does* apply a dot
|
|
||||||
* boundary, because there the entries are whole package roots.
|
|
||||||
*/
|
|
||||||
internal object ChinaPackageMatcher {
|
|
||||||
|
|
||||||
fun isSkipped(packageName: String): Boolean = SKIPPED_PREFIXES.any {
|
|
||||||
packageName == it || packageName.startsWith("$it.")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun matchesKnownPrefix(name: String): Boolean = name.matches(CHINA_PACKAGE_REGEX)
|
|
||||||
|
|
||||||
fun classNameOf(descriptor: String): String = descriptor
|
|
||||||
.removeSurrounding("L", ";")
|
|
||||||
.replace('/', '.')
|
|
||||||
.replace('$', '.')
|
|
||||||
|
|
||||||
private val SKIPPED_PREFIXES = listOf(
|
|
||||||
"com.google",
|
|
||||||
"com.android.chrome",
|
|
||||||
"com.android.vending",
|
|
||||||
"com.microsoft",
|
|
||||||
"com.apple",
|
|
||||||
"com.zhiliaoapp.musically",
|
|
||||||
// Caught by the loose "com.mx" prefix, which targets Maxthon's
|
|
||||||
// com.mx.browser. MX Player is unrelated.
|
|
||||||
"com.mxtech",
|
|
||||||
// Caught by the loose "com.stub" prefix, which targets the
|
|
||||||
// com.stub.StubApp packer. StubHub is unrelated.
|
|
||||||
"com.stubhub",
|
|
||||||
)
|
|
||||||
|
|
||||||
private val CHINA_PACKAGE_REGEX = listOf(
|
|
||||||
"com.tencent",
|
|
||||||
"com.alibaba",
|
|
||||||
"com.umeng",
|
|
||||||
"com.qihoo",
|
|
||||||
"com.ali",
|
|
||||||
"com.alipay",
|
|
||||||
"com.amap",
|
|
||||||
"com.sina",
|
|
||||||
"com.weibo",
|
|
||||||
"com.vivo",
|
|
||||||
"com.xiaomi",
|
|
||||||
"com.huawei",
|
|
||||||
"com.taobao",
|
|
||||||
"com.secneo",
|
|
||||||
"s.h.e.l.l",
|
|
||||||
"com.stub",
|
|
||||||
"com.kiwisec",
|
|
||||||
"com.secshell",
|
|
||||||
"com.wrapper",
|
|
||||||
"cn.securitystack",
|
|
||||||
"com.mogosec",
|
|
||||||
"com.secoen",
|
|
||||||
"com.netease",
|
|
||||||
"com.mx",
|
|
||||||
"com.qq.e",
|
|
||||||
"com.baidu",
|
|
||||||
"com.bytedance",
|
|
||||||
"com.bugly",
|
|
||||||
"com.miui",
|
|
||||||
"com.oppo",
|
|
||||||
"com.coloros",
|
|
||||||
"com.iqoo",
|
|
||||||
"com.meizu",
|
|
||||||
"com.gionee",
|
|
||||||
"cn.nubia",
|
|
||||||
"com.oplus",
|
|
||||||
"andes.oplus",
|
|
||||||
"com.unionpay",
|
|
||||||
"cn.wps",
|
|
||||||
).joinToString("|", prefix = "(", postfix = ").*") { prefix ->
|
|
||||||
Regex.escape(prefix)
|
|
||||||
}.toRegex()
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,541 +2,182 @@ package com.follow.clash.plugins
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.ActivityManager
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.pm.ApplicationInfo
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.VpnService
|
import android.net.ConnectivityManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.widget.Toast
|
||||||
import android.os.Looper
|
import androidx.annotation.RequiresApi
|
||||||
import android.provider.Settings
|
import androidx.core.content.getSystemService
|
||||||
import androidx.core.app.ActivityCompat
|
import com.follow.clash.extensions.getBase64
|
||||||
import androidx.core.content.ContextCompat
|
import com.follow.clash.extensions.getProtocol
|
||||||
import androidx.core.content.ContextCompat.getSystemService
|
import com.follow.clash.models.Metadata
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat
|
import com.follow.clash.models.Package
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.core.view.OnApplyWindowInsetsListener
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import com.follow.clash.R
|
|
||||||
import com.follow.clash.common.Components
|
|
||||||
import com.follow.clash.common.GlobalState
|
|
||||||
import com.follow.clash.common.PendingCallback
|
|
||||||
import com.follow.clash.common.QuickAction
|
|
||||||
import com.follow.clash.common.quickIntent
|
|
||||||
import com.follow.clash.common.registerReceiverCompat
|
|
||||||
import com.follow.clash.common.shortcutId
|
|
||||||
import com.follow.clash.getPackageIconPath
|
|
||||||
import com.follow.clash.packages.PackageResolver
|
|
||||||
import com.follow.clash.showToast
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
|
||||||
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
|
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
|
||||||
|
|
||||||
private var activity: Activity? = null
|
private var activity: Activity? = null
|
||||||
|
|
||||||
private var activityBinding: ActivityPluginBinding? = null
|
private var toast: Toast? = null
|
||||||
|
|
||||||
private val activityResultListener =
|
private var context: Context? = null
|
||||||
PluginRegistry.ActivityResultListener(::onActivityResult)
|
|
||||||
|
|
||||||
private val permissionsResultListener =
|
|
||||||
PluginRegistry.RequestPermissionsResultListener(::onRequestPermissionsResultListener)
|
|
||||||
|
|
||||||
private lateinit var channel: MethodChannel
|
private lateinit var channel: MethodChannel
|
||||||
|
|
||||||
private lateinit var scope: CoroutineScope
|
private lateinit var scope: CoroutineScope
|
||||||
|
|
||||||
private val vpnPrepareCallback = PendingCallback<Boolean>()
|
private var connectivity: ConnectivityManager? = null
|
||||||
|
|
||||||
private val requestNotificationCallback = PendingCallback<Boolean>()
|
private val iconMap = mutableMapOf<String, String?>()
|
||||||
|
|
||||||
private val requestInstalledAppsCallback = PendingCallback<Boolean>()
|
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
context = flutterPluginBinding.applicationContext;
|
||||||
private val requestLocalNetworkCallback = PendingCallback<Boolean>()
|
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "app")
|
||||||
|
channel.setMethodCallHandler(this)
|
||||||
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 packageChangeContext: Context? = null
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
channel.setMethodCallHandler(null)
|
||||||
|
}
|
||||||
|
|
||||||
private val packageChangeReceiver = object : BroadcastReceiver() {
|
private fun tip(message: String?) {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
if (toast != null) {
|
||||||
if (intent == null || intent.data?.schemeSpecificPart == GlobalState.application.packageName) {
|
toast!!.cancel()
|
||||||
return
|
|
||||||
}
|
|
||||||
val addedByUpdate = intent.action == Intent.ACTION_PACKAGE_ADDED &&
|
|
||||||
intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
|
|
||||||
if (addedByUpdate) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
packageResolver.invalidate()
|
|
||||||
channel.invokeMethod("packagesChanged", null)
|
|
||||||
}
|
}
|
||||||
|
toast = Toast.makeText(context, message, Toast.LENGTH_SHORT)
|
||||||
|
toast!!.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var skipNotificationPermissionRequest = false
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
@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) {
|
when (call.method) {
|
||||||
"moveTaskToBack" -> {
|
"moveTaskToBack" -> {
|
||||||
activity?.moveTaskToBack(true)
|
activity?.moveTaskToBack(true)
|
||||||
result.success(true)
|
result.success(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
"updateExcludeFromRecents" -> {
|
"getPackages" -> {
|
||||||
val value = call.argument<Boolean>("value")
|
scope.launch {
|
||||||
updateExcludeFromRecents(value)
|
result.success(getPackages())
|
||||||
result.success(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
"initShortcuts" -> {
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"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" -> {
|
"getPackageIcon" -> {
|
||||||
handleGetPackageIcon(call, result)
|
scope.launch {
|
||||||
|
val packageName = call.argument<String>("packageName")
|
||||||
|
if (packageName != null) {
|
||||||
|
result.success(getPackageIcon(packageName))
|
||||||
|
} else {
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"getPackageName" -> {
|
||||||
|
val data = call.argument<String>("data")
|
||||||
|
val metadata =
|
||||||
|
if (data != null) Gson().fromJson(
|
||||||
|
data,
|
||||||
|
Metadata::class.java
|
||||||
|
) else null
|
||||||
|
val protocol = metadata?.getProtocol()
|
||||||
|
if (protocol == null) {
|
||||||
|
result.success(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
if (context == null) result.success(null)
|
||||||
|
val source = InetSocketAddress(metadata.sourceIP, metadata.sourcePort)
|
||||||
|
val target = InetSocketAddress(
|
||||||
|
metadata.host.ifEmpty { metadata.destinationIP },
|
||||||
|
metadata.destinationPort
|
||||||
|
)
|
||||||
|
if (connectivity == null) {
|
||||||
|
connectivity = context!!.getSystemService<ConnectivityManager>()
|
||||||
|
}
|
||||||
|
val uid =
|
||||||
|
connectivity?.getConnectionOwnerUid(protocol, source, target)
|
||||||
|
if (uid == null || uid == -1) {
|
||||||
|
result.success(null)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
val packages = context?.packageManager?.getPackagesForUid(uid)
|
||||||
|
result.success(packages?.first())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"tip" -> {
|
"tip" -> {
|
||||||
val message = call.argument<String>("message")
|
val message = call.argument<String>("message")
|
||||||
GlobalState.application.showToast(message)
|
tip(message)
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
|
||||||
|
|
||||||
"openAppSettings" -> {
|
|
||||||
result.success(openAppSettings())
|
|
||||||
}
|
|
||||||
|
|
||||||
"getLastExitInfo" -> reply(result) {
|
|
||||||
GlobalState.lastExitInfo()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
result.notImplemented()
|
result.notImplemented();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleGetPackageIcon(call: MethodCall, result: Result) = reply(result) {
|
private suspend fun getPackageIcon(packageName: String): String? {
|
||||||
val packageName = call.argument<String>("packageName") ?: ""
|
val packageManager = context?.packageManager
|
||||||
GlobalState.application.packageManager.getPackageIconPath(packageName)
|
if (iconMap[packageName] == null) {
|
||||||
|
iconMap[packageName] = packageManager?.getApplicationIcon(packageName)?.getBase64()
|
||||||
|
}
|
||||||
|
return iconMap[packageName]
|
||||||
}
|
}
|
||||||
|
|
||||||
// A throw inside the plugin scope has no handler and ends the process;
|
private suspend fun getPackages(): String {
|
||||||
// the Dart caller gets a PlatformException to handle instead.
|
return withContext(Dispatchers.Default){
|
||||||
private fun reply(result: Result, block: suspend () -> Any?) {
|
val packageManager = context?.packageManager
|
||||||
scope.launch(Dispatchers.IO) {
|
val packages: List<Package>? =
|
||||||
try {
|
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA)?.filter {
|
||||||
result.success(block())
|
it.packageName != context?.packageName
|
||||||
} catch (error: Exception) {
|
|| it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|
||||||
GlobalState.log("Platform call failed: $error")
|
|| it.packageName == "android"
|
||||||
result.error("PLATFORM_ERROR", error.toString(), null)
|
|
||||||
}
|
}?.map {
|
||||||
|
Package(
|
||||||
|
packageName = it.packageName,
|
||||||
|
label = it.applicationInfo.loadLabel(packageManager).toString(),
|
||||||
|
isSystem = (it.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM) == 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Gson().toJson(packages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun shortcutLabels(arguments: Any?): Map<QuickAction, String>? {
|
|
||||||
val map = arguments as? Map<*, *> ?: return null
|
|
||||||
return QuickAction.entries.associateWith { action ->
|
|
||||||
map[action.shortcutId] as? String ?: return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initShortcuts(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 == taskId
|
|
||||||
} else {
|
|
||||||
it.taskInfo?.id == taskId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
task?.setExcludeFromRecents(value ?: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
|
|
||||||
invokeRequestNotificationCallback(true)
|
|
||||||
return@onMainThread
|
|
||||||
}
|
|
||||||
if (isRequestingNotificationPermission) {
|
|
||||||
return@onMainThread
|
|
||||||
}
|
|
||||||
isRequestingNotificationPermission = true
|
|
||||||
activity?.let {
|
|
||||||
ActivityCompat.requestPermissions(
|
|
||||||
it,
|
|
||||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
|
||||||
NOTIFICATION_PERMISSION_REQUEST_CODE,
|
|
||||||
)
|
|
||||||
} ?: invokeRequestNotificationCallback(true)
|
|
||||||
return@onMainThread
|
|
||||||
}
|
|
||||||
invokeRequestNotificationCallback(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invokeRequestNotificationCallback(shouldStart: Boolean) {
|
|
||||||
isRequestingNotificationPermission = false
|
|
||||||
requestNotificationCallback.resolve(shouldStart)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) = onMainThread {
|
|
||||||
requestLocalNetworkCallback.replace(callback, supersededValue = false)
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN || hasLocalNetworkPermission()) {
|
|
||||||
invokeRequestLocalNetworkCallback(true)
|
|
||||||
return@onMainThread
|
|
||||||
}
|
|
||||||
if (skipLocalNetworkPermissionRequest) {
|
|
||||||
invokeRequestLocalNetworkCallback(false)
|
|
||||||
return@onMainThread
|
|
||||||
}
|
|
||||||
if (isRequestingLocalNetworkPermission) {
|
|
||||||
return@onMainThread
|
|
||||||
}
|
|
||||||
val activity = activity
|
|
||||||
if (activity == null) {
|
|
||||||
invokeRequestLocalNetworkCallback(false)
|
|
||||||
return@onMainThread
|
|
||||||
}
|
|
||||||
isRequestingLocalNetworkPermission = true
|
|
||||||
ActivityCompat.requestPermissions(
|
|
||||||
activity,
|
|
||||||
arrayOf(Manifest.permission.ACCESS_LOCAL_NETWORK),
|
|
||||||
LOCAL_NETWORK_PERMISSION_REQUEST_CODE,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasLocalNetworkPermission(): Boolean = ContextCompat.checkSelfPermission(
|
|
||||||
GlobalState.application,
|
|
||||||
Manifest.permission.ACCESS_LOCAL_NETWORK,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
private fun invokeRequestLocalNetworkCallback(granted: Boolean) {
|
|
||||||
isRequestingLocalNetworkPermission = false
|
|
||||||
requestLocalNetworkCallback.resolve(granted)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestInstalledAppsPermission(callback: (Boolean) -> Unit) = onMainThread {
|
|
||||||
requestInstalledAppsCallback.replace(callback, supersededValue = false)
|
|
||||||
val activity = activity
|
|
||||||
if (packageResolver.hasInstalledAppsPermission()) {
|
|
||||||
invokeRequestInstalledAppsCallback(true)
|
|
||||||
return@onMainThread
|
|
||||||
}
|
|
||||||
if (activity == null) {
|
|
||||||
invokeRequestInstalledAppsCallback(false)
|
|
||||||
return@onMainThread
|
|
||||||
}
|
|
||||||
ActivityCompat.requestPermissions(
|
|
||||||
activity,
|
|
||||||
arrayOf(PackageResolver.GET_INSTALLED_APPS),
|
|
||||||
INSTALLED_APPS_PERMISSION_REQUEST_CODE,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invokeRequestInstalledAppsCallback(granted: Boolean) {
|
|
||||||
if (granted) {
|
|
||||||
packageResolver.invalidate()
|
|
||||||
}
|
|
||||||
requestInstalledAppsCallback.resolve(granted)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) = onMainThread {
|
|
||||||
vpnPrepareCallback.replace(callback, supersededValue = false)
|
|
||||||
if (!needPrepare) {
|
|
||||||
invokeVpnPrepareCallback(true)
|
|
||||||
return@onMainThread
|
|
||||||
}
|
|
||||||
val intent = VpnService.prepare(GlobalState.application)
|
|
||||||
if (intent != null) {
|
|
||||||
val activity = activity
|
|
||||||
if (activity == null) {
|
|
||||||
invokeVpnPrepareCallback(false)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
|
||||||
}
|
|
||||||
return@onMainThread
|
|
||||||
}
|
|
||||||
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(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) {
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
attachToActivity(binding)
|
activity = binding.activity;
|
||||||
}
|
scope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
||||||
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() {
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
detachFromActivity()
|
activity = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
attachToActivity(binding)
|
activity = binding.activity;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivity() {
|
override fun onDetachedFromActivity() {
|
||||||
channel.invokeMethod("exit", null)
|
channel.invokeMethod("exit", null)
|
||||||
detachFromActivity()
|
scope.cancel()
|
||||||
invokeVpnPrepareCallback(false)
|
activity = null;
|
||||||
invokeRequestNotificationCallback(false)
|
|
||||||
invokeRequestLocalNetworkCallback(false)
|
|
||||||
invokeRequestInstalledAppsCallback(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
|
||||||
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 = when (requestCode) {
|
|
||||||
NOTIFICATION_PERMISSION_REQUEST_CODE -> {
|
|
||||||
skipNotificationPermissionRequest = true
|
|
||||||
invokeRequestNotificationCallback(true)
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
package com.follow.clash.plugins
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.database.ContentObserver
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import com.follow.clash.common.Components
|
|
||||||
import com.follow.clash.common.PendingCallback
|
|
||||||
import com.follow.clash.common.registerReceiverCompat
|
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
|
||||||
import io.flutter.plugin.common.EventChannel
|
|
||||||
import io.flutter.plugin.common.MethodCall
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import io.flutter.plugin.common.PluginRegistry
|
|
||||||
|
|
||||||
class BatteryPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware,
|
|
||||||
EventChannel.StreamHandler {
|
|
||||||
|
|
||||||
private lateinit var context: Context
|
|
||||||
|
|
||||||
private lateinit var methodChannel: MethodChannel
|
|
||||||
|
|
||||||
private lateinit var eventChannel: EventChannel
|
|
||||||
|
|
||||||
private var activityBinding: ActivityPluginBinding? = null
|
|
||||||
|
|
||||||
private val pendingRequest = PendingCallback<Unit>()
|
|
||||||
|
|
||||||
private var events: EventChannel.EventSink? = null
|
|
||||||
|
|
||||||
private var lastPublished: Boolean? = null
|
|
||||||
|
|
||||||
private val activityResultListener =
|
|
||||||
PluginRegistry.ActivityResultListener { requestCode, _, _ ->
|
|
||||||
if (requestCode != REQUEST_CODE) {
|
|
||||||
return@ActivityResultListener false
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
private val allowlistReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
publish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val saverObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
|
||||||
override fun onChange(selfChange: Boolean) {
|
|
||||||
publish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
|
||||||
context = binding.applicationContext
|
|
||||||
methodChannel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/battery")
|
|
||||||
methodChannel.setMethodCallHandler(this)
|
|
||||||
eventChannel = EventChannel(
|
|
||||||
binding.binaryMessenger,
|
|
||||||
"${Components.PACKAGE_NAME}/battery/optimization",
|
|
||||||
)
|
|
||||||
eventChannel.setStreamHandler(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
|
||||||
methodChannel.setMethodCallHandler(null)
|
|
||||||
eventChannel.setStreamHandler(null)
|
|
||||||
onCancel(null)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
when (call.method) {
|
|
||||||
"requestIgnoreOptimizations" -> requestIgnoreOptimizations { result.success(null) }
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
|
|
||||||
val ignoring = isIgnoringOptimizations()
|
|
||||||
sink.success(ignoring)
|
|
||||||
context.registerReceiverCompat(
|
|
||||||
allowlistReceiver,
|
|
||||||
IntentFilter(ACTION_POWER_SAVE_WHITELIST_CHANGED),
|
|
||||||
)
|
|
||||||
context.contentResolver.registerContentObserver(
|
|
||||||
Settings.System.getUriFor(MILLET_NO_RESTRICT_APP),
|
|
||||||
false,
|
|
||||||
saverObserver,
|
|
||||||
)
|
|
||||||
events = sink
|
|
||||||
lastPublished = ignoring
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCancel(arguments: Any?) {
|
|
||||||
events ?: return
|
|
||||||
events = null
|
|
||||||
context.unregisterReceiver(allowlistReceiver)
|
|
||||||
context.contentResolver.unregisterContentObserver(saverObserver)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun publish() {
|
|
||||||
val sink = events ?: return
|
|
||||||
val ignoring = isIgnoringOptimizations()
|
|
||||||
if (ignoring == lastPublished) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastPublished = ignoring
|
|
||||||
sink.success(ignoring)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolve() {
|
|
||||||
publish()
|
|
||||||
pendingRequest.resolve(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestIgnoreOptimizations(callback: (Unit) -> Unit) {
|
|
||||||
val activity = activityBinding?.activity
|
|
||||||
if (activity == null || isIgnoringOptimizations()) {
|
|
||||||
callback(Unit)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pendingRequest.replace(callback, supersededValue = Unit)
|
|
||||||
val pages = if (isXiaomi) saverPages() else optimizationPages()
|
|
||||||
if (pages.none { launch(activity, it) }) {
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("BatteryLife")
|
|
||||||
private fun optimizationPages(): List<Intent> {
|
|
||||||
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
|
|
||||||
val prompt = Intent(
|
|
||||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
|
||||||
"package:${context.packageName}".toUri(),
|
|
||||||
)
|
|
||||||
// Some ROMs drop the direct prompt; the allowlist page still lets the user exempt the app.
|
|
||||||
val allowlist = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
|
||||||
return listOf(prompt, allowlist)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saverPages(): List<Intent> {
|
|
||||||
val policy = Intent()
|
|
||||||
.setComponent(ComponentName(POWERKEEPER_PACKAGE, POWERKEEPER_POLICY_ACTIVITY))
|
|
||||||
.putExtra("package_name", context.packageName)
|
|
||||||
.putExtra(
|
|
||||||
"package_label",
|
|
||||||
context.applicationInfo.loadLabel(context.packageManager).toString(),
|
|
||||||
)
|
|
||||||
val details = Intent(
|
|
||||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
|
||||||
"package:${context.packageName}".toUri(),
|
|
||||||
)
|
|
||||||
return listOf(policy, details)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun launch(activity: Activity, page: Intent): Boolean = try {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
activity.startActivityForResult(page, REQUEST_CODE)
|
|
||||||
true
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
false
|
|
||||||
} catch (_: SecurityException) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
// On Xiaomi the battery saver is what counts: "No restrictions" also allowlists the
|
|
||||||
// app, the allowlist alone does not stop PowerKeeper freezing it, and PowerKeeper
|
|
||||||
// drops a running VPN from the allowlist while the list follows the user's policy.
|
|
||||||
private fun isIgnoringOptimizations(): Boolean {
|
|
||||||
if (isXiaomi) {
|
|
||||||
return context.packageName in noRestrictionApps()
|
|
||||||
}
|
|
||||||
return context.getSystemService(PowerManager::class.java)
|
|
||||||
?.isIgnoringBatteryOptimizations(context.packageName) ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
// PowerKeeper's comma-separated "No restrictions" packages, kept for user 0 only.
|
|
||||||
private fun noRestrictionApps(): List<String> {
|
|
||||||
val packages = try {
|
|
||||||
Settings.System.getString(context.contentResolver, MILLET_NO_RESTRICT_APP)
|
|
||||||
} catch (_: SecurityException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
return packages?.split(',')?.map { it.trim() }.orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
|
||||||
activityBinding = binding
|
|
||||||
binding.addActivityResultListener(activityResultListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromActivityForConfigChanges() {
|
|
||||||
detachFromActivity()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
|
||||||
onAttachedToActivity(binding)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromActivity() {
|
|
||||||
detachFromActivity()
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun detachFromActivity() {
|
|
||||||
activityBinding?.removeActivityResultListener(activityResultListener)
|
|
||||||
activityBinding = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val isXiaomi = Build.MANUFACTURER.equals("xiaomi", ignoreCase = true)
|
|
||||||
|
|
||||||
// Every plugin on the activity sees every result; AppPlugin holds 1001-1004.
|
|
||||||
const val REQUEST_CODE = 1005
|
|
||||||
|
|
||||||
// PowerManager keeps this action @hide, yet the system broadcasts it to
|
|
||||||
// registered receivers of the system user whenever the allowlist changes.
|
|
||||||
const val ACTION_POWER_SAVE_WHITELIST_CHANGED =
|
|
||||||
"android.os.action.POWER_SAVE_WHITELIST_CHANGED"
|
|
||||||
|
|
||||||
const val MILLET_NO_RESTRICT_APP = "MILLET_NO_RESTRICT_APP"
|
|
||||||
|
|
||||||
const val POWERKEEPER_PACKAGE = "com.miui.powerkeeper"
|
|
||||||
|
|
||||||
const val POWERKEEPER_POLICY_ACTIVITY = "com.miui.powerkeeper.ui.HiddenAppsConfigActivity"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package com.follow.clash.plugins
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build.VERSION
|
||||||
|
import android.os.Build.VERSION_CODES
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.follow.clash.GlobalState
|
||||||
|
import com.follow.clash.RunState
|
||||||
|
import com.follow.clash.models.AccessControl
|
||||||
|
import com.follow.clash.services.FlClashVpnService
|
||||||
|
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 java.util.Date
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
|
||||||
|
|
||||||
|
|
||||||
|
val VPN_PERMISSION_REQUEST_CODE = 1001
|
||||||
|
val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
|
||||||
|
|
||||||
|
private lateinit var flutterMethodChannel: MethodChannel
|
||||||
|
|
||||||
|
private var activity: Activity? = null
|
||||||
|
private var context: Context? = null
|
||||||
|
private var flClashVpnService: FlClashVpnService? = null
|
||||||
|
private var isBound = false
|
||||||
|
private var port: Int? = null
|
||||||
|
private var accessControl: AccessControl? = null
|
||||||
|
private lateinit var title: String
|
||||||
|
private lateinit var content: String
|
||||||
|
|
||||||
|
private val connection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||||
|
val binder = service as FlClashVpnService.LocalBinder
|
||||||
|
flClashVpnService = binder.getService()
|
||||||
|
port?.let { startVpn(it) }
|
||||||
|
isBound = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||||
|
flClashVpnService = null
|
||||||
|
isBound = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
context = flutterPluginBinding.applicationContext
|
||||||
|
flutterMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "proxy")
|
||||||
|
flutterMethodChannel.setMethodCallHandler(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
flutterMethodChannel.setMethodCallHandler(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
|
||||||
|
"StartProxy" -> {
|
||||||
|
port = call.argument<Int>("port")
|
||||||
|
val args = call.argument<String>("args")
|
||||||
|
accessControl =
|
||||||
|
if (args != null) Gson().fromJson(args, AccessControl::class.java) else null
|
||||||
|
handleStartVpn()
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
"StopProxy" -> {
|
||||||
|
stopVpn()
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
"SetProtect" -> {
|
||||||
|
val fd = call.argument<Int>("fd")
|
||||||
|
if (fd != null) {
|
||||||
|
flClashVpnService?.protect(fd)
|
||||||
|
result.success(true)
|
||||||
|
} else {
|
||||||
|
result.success(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"GetRunTimeStamp" -> {
|
||||||
|
result.success(GlobalState.runTime?.time)
|
||||||
|
}
|
||||||
|
|
||||||
|
"startForeground" -> {
|
||||||
|
title = call.argument<String>("title") as String
|
||||||
|
content = call.argument<String>("content") as String
|
||||||
|
requestNotificationsPermission()
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleStartVpn() {
|
||||||
|
val intent = VpnService.prepare(context)
|
||||||
|
if (intent != null) {
|
||||||
|
activity?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
||||||
|
} else {
|
||||||
|
bindService()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startVpn(port: Int) {
|
||||||
|
if (GlobalState.runState.value == RunState.START) return;
|
||||||
|
flClashVpnService?.start(port, accessControl)
|
||||||
|
GlobalState.runState.value = RunState.START
|
||||||
|
GlobalState.runTime = Date()
|
||||||
|
startAfter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopVpn() {
|
||||||
|
if (GlobalState.runState.value == RunState.STOP) return
|
||||||
|
flClashVpnService?.stop()
|
||||||
|
unbindService()
|
||||||
|
GlobalState.runState.value = RunState.STOP;
|
||||||
|
GlobalState.runTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ForegroundServiceType")
|
||||||
|
private fun startForeground() {
|
||||||
|
if (GlobalState.runState.value != RunState.START) return
|
||||||
|
flClashVpnService?.startForeground(title, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationsPermission() {
|
||||||
|
if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
|
||||||
|
val permission = context?.let {
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
it,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (permission == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
startForeground()
|
||||||
|
} else {
|
||||||
|
activity?.let {
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
it,
|
||||||
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
NOTIFICATION_PERMISSION_REQUEST_CODE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startForeground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startAfter() {
|
||||||
|
flutterMethodChannel.invokeMethod("startAfter", flClashVpnService?.fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
activity = binding.activity
|
||||||
|
binding.addActivityResultListener(::onActivityResult)
|
||||||
|
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||||
|
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
|
||||||
|
if (resultCode == FlutterActivity.RESULT_OK) {
|
||||||
|
bindService()
|
||||||
|
} else {
|
||||||
|
stopVpn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onRequestPermissionsResultListener(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<String>,
|
||||||
|
grantResults: IntArray
|
||||||
|
): Boolean {
|
||||||
|
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
|
||||||
|
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
startForeground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
|
activity = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
|
activity = binding.activity
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivity() {
|
||||||
|
stopVpn()
|
||||||
|
activity = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindService() {
|
||||||
|
val intent = Intent(context, FlClashVpnService::class.java)
|
||||||
|
context?.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unbindService() {
|
||||||
|
if (isBound) {
|
||||||
|
context?.unbindService(connection)
|
||||||
|
isBound = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
package com.follow.clash.plugins
|
|
||||||
|
|
||||||
import com.follow.clash.ServiceController
|
|
||||||
import com.follow.clash.ServiceState
|
|
||||||
import com.follow.clash.common.Components
|
|
||||||
import com.follow.clash.models.SharedState
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
|
||||||
import io.flutter.plugin.common.MethodCall
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import io.flutter.plugin.common.StandardMethodCodec
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|
||||||
private lateinit var messenger: BinaryMessenger
|
|
||||||
private lateinit var channel: MethodChannel
|
|
||||||
private lateinit var coreChannel: MethodChannel
|
|
||||||
private lateinit var scope: CoroutineScope
|
|
||||||
private val gson = Gson()
|
|
||||||
private var commandRevision = 0
|
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
||||||
messenger = binding.binaryMessenger
|
|
||||||
channel = MethodChannel(messenger, SERVICE_CHANNEL)
|
|
||||||
channel.setMethodCallHandler(this)
|
|
||||||
// Serial: watchRoute keeps the latest intent by the order calls reach the Core.
|
|
||||||
coreChannel = MethodChannel(
|
|
||||||
messenger,
|
|
||||||
CORE_CHANNEL,
|
|
||||||
StandardMethodCodec.INSTANCE,
|
|
||||||
messenger.makeBackgroundTaskQueue(),
|
|
||||||
)
|
|
||||||
coreChannel.setMethodCallHandler(::onCoreMethodCall)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
|
||||||
channel.setMethodCallHandler(null)
|
|
||||||
coreChannel.setMethodCallHandler(null)
|
|
||||||
scope.cancel()
|
|
||||||
ServiceController.setEventListener(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 onCoreMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
when (call.method) {
|
|
||||||
"invokeMethod" -> invokeMethod(call, result)
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 stop(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
commandRevision = call.arguments as? Int ?: commandRevision
|
|
||||||
ServiceState.requestStop()
|
|
||||||
result.success(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 sendEvent(value: ByteArray) {
|
|
||||||
val message = StandardMethodCodec.INSTANCE.encodeMethodCall(MethodCall("event", value))
|
|
||||||
scope.launch(Dispatchers.Main) {
|
|
||||||
messenger.send(SERVICE_CHANNEL, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val SERVICE_CHANNEL = "${Components.PACKAGE_NAME}/service"
|
|
||||||
val CORE_CHANNEL = "${Components.PACKAGE_NAME}/core"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,37 @@
|
|||||||
package com.follow.clash.plugins
|
package com.follow.clash.plugins
|
||||||
|
|
||||||
import com.follow.clash.common.Components
|
|
||||||
import com.follow.clash.invokeMethodOnMainThread
|
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
class TilePlugin(private val onStart: (() -> Unit)? = null, private val onStop: (() -> Unit)? = null) : FlutterPlugin,
|
||||||
private lateinit var channel: MethodChannel
|
MethodChannel.MethodCallHandler {
|
||||||
|
|
||||||
|
private lateinit var channel: MethodChannel
|
||||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
channel =
|
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "tile")
|
||||||
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/tile")
|
|
||||||
channel.setMethodCallHandler(this)
|
channel.setMethodCallHandler(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
handleDetached()
|
||||||
channel.setMethodCallHandler(null)
|
channel.setMethodCallHandler(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleStart() {
|
fun handleStart() {
|
||||||
channel.invokeMethodOnMainThread("start")
|
onStart?.let { it() }
|
||||||
|
channel.invokeMethod("start", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
fun handleStop() {
|
||||||
result.notImplemented()
|
channel.invokeMethod("stop", null)
|
||||||
|
onStop?.let { it() }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private fun handleDetached() {
|
||||||
|
channel.invokeMethod("detached", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.follow.clash.services
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.service.quicksettings.Tile
|
||||||
|
import android.service.quicksettings.TileService
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import com.follow.clash.GlobalState
|
||||||
|
import com.follow.clash.RunState
|
||||||
|
import com.follow.clash.TempActivity
|
||||||
|
import com.follow.clash.plugins.AppPlugin
|
||||||
|
import com.follow.clash.plugins.ProxyPlugin
|
||||||
|
import com.follow.clash.plugins.TilePlugin
|
||||||
|
import io.flutter.FlutterInjector
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.embedding.engine.dart.DartExecutor
|
||||||
|
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.N)
|
||||||
|
class FlClashTileService : TileService() {
|
||||||
|
|
||||||
|
private val observer = Observer<RunState> { runState ->
|
||||||
|
updateTile(runState)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
GlobalState.runState.value?.let { updateTile(it) }
|
||||||
|
GlobalState.runState.observeForever(observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("StartActivityAndCollapseDeprecated")
|
||||||
|
private fun activityTransfer() {
|
||||||
|
val intent = Intent(this, TempActivity::class.java)
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
if (Build.VERSION.SDK_INT >= 34) {
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
startActivityAndCollapse(pendingIntent)
|
||||||
|
} else {
|
||||||
|
startActivityAndCollapse(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var flutterEngine: FlutterEngine? = null;
|
||||||
|
|
||||||
|
private fun initFlutterEngine() {
|
||||||
|
flutterEngine = FlutterEngine(this)
|
||||||
|
flutterEngine?.plugins?.add(ProxyPlugin())
|
||||||
|
flutterEngine?.plugins?.add(TilePlugin())
|
||||||
|
flutterEngine?.plugins?.add(AppPlugin())
|
||||||
|
GlobalState.flutterEngine = flutterEngine
|
||||||
|
if (flutterEngine?.dartExecutor?.isExecutingDart != true) {
|
||||||
|
val vpnService = DartExecutor.DartEntrypoint(
|
||||||
|
FlutterInjector.instance().flutterLoader().findAppBundlePath(),
|
||||||
|
"vpnService"
|
||||||
|
)
|
||||||
|
flutterEngine?.dartExecutor?.executeDartEntrypoint(vpnService)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick() {
|
||||||
|
super.onClick()
|
||||||
|
activityTransfer()
|
||||||
|
val currentTilePlugin = GlobalState.getCurrentTilePlugin()
|
||||||
|
if (GlobalState.runState.value == RunState.STOP) {
|
||||||
|
GlobalState.runState.value = RunState.PENDING
|
||||||
|
if(currentTilePlugin == null){
|
||||||
|
initFlutterEngine()
|
||||||
|
}else{
|
||||||
|
currentTilePlugin.handleStart()
|
||||||
|
}
|
||||||
|
} else if(GlobalState.runState.value == RunState.START){
|
||||||
|
GlobalState.runState.value = RunState.PENDING
|
||||||
|
currentTilePlugin?.handleStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
GlobalState.runState.removeObserver(observer)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.follow.clash.services
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
|
import android.net.ProxyInfo
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
|
||||||
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import com.follow.clash.GlobalState
|
||||||
|
import com.follow.clash.MainActivity
|
||||||
|
import com.follow.clash.models.AccessControl
|
||||||
|
import com.follow.clash.models.AccessControlMode
|
||||||
|
|
||||||
|
|
||||||
|
@SuppressLint("WrongConstant")
|
||||||
|
class FlClashVpnService : VpnService() {
|
||||||
|
|
||||||
|
|
||||||
|
private val CHANNEL = "FlClash"
|
||||||
|
|
||||||
|
var fd: Int? = null
|
||||||
|
private val notificationId: Int = 1
|
||||||
|
|
||||||
|
private val passList = listOf(
|
||||||
|
"*zhihu.com",
|
||||||
|
"*zhimg.com",
|
||||||
|
"*jd.com",
|
||||||
|
"100ime-iat-api.xfyun.cn",
|
||||||
|
"*360buyimg.com",
|
||||||
|
"localhost",
|
||||||
|
"*.local",
|
||||||
|
"127.*",
|
||||||
|
"10.*",
|
||||||
|
"172.16.*",
|
||||||
|
"172.17.*",
|
||||||
|
"172.18.*",
|
||||||
|
"172.19.*",
|
||||||
|
"172.2*",
|
||||||
|
"172.30.*",
|
||||||
|
"172.31.*",
|
||||||
|
"192.168.*"
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(port: Int, accessControl: AccessControl?) {
|
||||||
|
fd = with(Builder()) {
|
||||||
|
addAddress("172.16.0.1", 30)
|
||||||
|
setMtu(9000)
|
||||||
|
addRoute("0.0.0.0", 0)
|
||||||
|
if (accessControl != null) {
|
||||||
|
when (accessControl.mode) {
|
||||||
|
AccessControlMode.acceptSelected -> {
|
||||||
|
(accessControl.acceptList + packageName).forEach {
|
||||||
|
addAllowedApplication(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessControlMode.rejectSelected -> {
|
||||||
|
(accessControl.rejectList - packageName).forEach {
|
||||||
|
addDisallowedApplication(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addDnsServer("172.16.0.2")
|
||||||
|
setSession("FlClash")
|
||||||
|
setBlocking(false)
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
setMetered(false)
|
||||||
|
}
|
||||||
|
allowBypass()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
setHttpProxy(
|
||||||
|
ProxyInfo.buildDirectProxy(
|
||||||
|
"127.0.0.1",
|
||||||
|
port,
|
||||||
|
passList
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
establish()?.detachFd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
stopSelf()
|
||||||
|
stopForeground()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private val notificationBuilder by lazy {
|
||||||
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
|
|
||||||
|
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val icon = IconCompat.createWithResource(this, this.applicationInfo.icon)
|
||||||
|
|
||||||
|
with(NotificationCompat.Builder(this, CHANNEL)) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
setSmallIcon(icon)
|
||||||
|
}
|
||||||
|
setContentTitle("FlClash")
|
||||||
|
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
|
||||||
|
setContentIntent(pendingIntent)
|
||||||
|
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
priority = NotificationCompat.PRIORITY_LOW
|
||||||
|
setOngoing(true)
|
||||||
|
setShowWhen(false)
|
||||||
|
setOnlyAlertOnce(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ForegroundServiceType", "WrongConstant")
|
||||||
|
fun startForeground(title: String, content: String) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel =
|
||||||
|
NotificationChannel(CHANNEL, "FlClash", NotificationManager.IMPORTANCE_LOW)
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
val notification =
|
||||||
|
notificationBuilder.setContentTitle(title).setContentText(content).build()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
startForeground(notificationId, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||||
|
} else {
|
||||||
|
startForeground(notificationId, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopForeground() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val binder = LocalBinder()
|
||||||
|
|
||||||
|
inner class LocalBinder : Binder() {
|
||||||
|
fun getService(): FlClashVpnService = this@FlClashVpnService
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder {
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnbind(intent: Intent?): Boolean {
|
||||||
|
GlobalState.getCurrentTilePlugin()?.handleStop();
|
||||||
|
return super.onUnbind(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stop()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="240"
|
|
||||||
android:viewportHeight="240">
|
|
||||||
<group android:scaleX="0.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>
|
|
||||||
</vector>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="240"
|
|
||||||
android:viewportHeight="240">
|
|
||||||
<group
|
|
||||||
android:scaleX="0.9"
|
|
||||||
android:scaleY="0.9"
|
|
||||||
android:translateX="15.23"
|
|
||||||
android:translateY="12.22">
|
|
||||||
<path
|
|
||||||
android:fillColor="#6666FB"
|
|
||||||
android:pathData="M60.65,89.6L154.18,35.6A18,18 107.59,0 1,178.77 42.19L178.77,42.19A18,18 107.59,0 1,172.18 66.78L78.65,120.78A18,18 106.67,0 1,54.06 114.19L54.06,114.19A18,18 106.67,0 1,60.65 89.6z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#336AB6"
|
|
||||||
android:pathData="M84.65,131.17L131.42,104.17A18,18 107.83,0 1,156 110.76L156,110.76A18,18 107.83,0 1,149.42 135.35L102.65,162.35A18,18 106.67,0 1,78.06 155.76L78.06,155.76A18,18 106.67,0 1,84.65 131.17z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#5CA8E9"
|
|
||||||
android:pathData="M108.65,172.74L108.65,172.74A18,18 116.03,0 1,133.24 179.33L133.24,179.33A18,18 116.03,0 1,126.65 203.92L126.65,203.92A18,18 116.03,0 1,102.06 197.33L102.06,197.33A18,18 116.03,0 1,108.65 172.74z" />
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="48dp"
|
|
||||||
android:height="48dp"
|
|
||||||
android:viewportWidth="48"
|
|
||||||
android:viewportHeight="48">
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/shortcut_background"
|
|
||||||
android:pathData="M24,2 A22,22 0 1 1 24,46 A22,22 0 1 1 24,2 Z" />
|
|
||||||
<group
|
|
||||||
android:scaleX="1.35"
|
|
||||||
android:scaleY="1.35"
|
|
||||||
android:translateX="7.8"
|
|
||||||
android:translateY="7.8">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M8.30,7.74 A1.2,1.2 0 0 1 10.12,6.71 L17.12,10.98 A1.2,1.2 0 0 1 17.12,13.02 L10.12,17.29 A1.2,1.2 0 0 1 8.30,16.26 Z"
|
|
||||||
android:strokeColor="#FFFFFF"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="1.8" />
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="48dp"
|
|
||||||
android:height="48dp"
|
|
||||||
android:viewportWidth="48"
|
|
||||||
android:viewportHeight="48">
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/shortcut_background"
|
|
||||||
android:pathData="M24,2 A22,22 0 1 1 24,46 A22,22 0 1 1 24,2 Z" />
|
|
||||||
<group
|
|
||||||
android:scaleX="1.35"
|
|
||||||
android:scaleY="1.35"
|
|
||||||
android:translateX="7.8"
|
|
||||||
android:translateY="7.8">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M14.90,7.00 A1.1,1.1 0 0 1 16.00,5.90 L16.00,5.90 A1.1,1.1 0 0 1 17.10,7.00 L17.10,17.00 A1.1,1.1 0 0 1 16.00,18.10 L16.00,18.10 A1.1,1.1 0 0 1 14.90,17.00 Z M6.90,7.00 A1.1,1.1 0 0 1 8.00,5.90 L8.00,5.90 A1.1,1.1 0 0 1 9.10,7.00 L9.10,17.00 A1.1,1.1 0 0 1 8.00,18.10 L8.00,18.10 A1.1,1.1 0 0 1 6.90,17.00 Z"
|
|
||||||
android:strokeColor="#FFFFFF"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="1.8" />
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="48dp"
|
|
||||||
android:height="48dp"
|
|
||||||
android:viewportWidth="48"
|
|
||||||
android:viewportHeight="48">
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/shortcut_background"
|
|
||||||
android:pathData="M24,2 A22,22 0 1 1 24,46 A22,22 0 1 1 24,2 Z" />
|
|
||||||
<group
|
|
||||||
android:scaleX="1.35"
|
|
||||||
android:scaleY="1.35"
|
|
||||||
android:translateX="7.8"
|
|
||||||
android:translateY="7.8">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M2.90,12.00 A5.5,5.5 0 0 1 8.40,6.50 L15.60,6.50 A5.5,5.5 0 0 1 21.10,12.00 L21.10,12.00 A5.5,5.5 0 0 1 15.60,17.50 L8.40,17.50 A5.5,5.5 0 0 1 2.90,12.00 Z"
|
|
||||||
android:strokeColor="#FFFFFF"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="1.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/shortcut_background"
|
|
||||||
android:pathData="M8.4,9.2 A2.8,2.8 0 1 1 8.4,14.8 A2.8,2.8 0 1 1 8.4,9.2 Z" />
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
|
After Width: | Height: | Size: 105 KiB |
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 886 B After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground_tv" />
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 782 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |