Compare commits

...

34 Commits

Author SHA1 Message Date
chen08209 68c71b8ef9 chore(release): v0.8.99 2026-10-03 19:03:12 +08:00
chen08209 ff23f30c31 fix(core): send changed geo resource URLs to the Core without a full apply
The Core only learned geox-url from a full config apply, so after a link
was edited on the resources page both a manual sync and the auto updater
kept downloading from the previous link until the profile was applied
again. The URLs now travel with the update params, which are pushed
whenever the patch config changes. Only a link that changed is written,
because mihomo's updaters read these links without a lock.

Changelog: A changed resource link is now used by the next sync and by auto update
2026-10-02 21:10:36 +08:00
chen08209 e55f6d1004 fix(core): keep the system resolver fallback when no DNS servers are reported
An Android service with no DNS servers sends an empty list, which split into
a single empty address that mihomo builds into a "udp://:" nameserver. The
system resolver then counted it as configured and never fell back to its
default nameservers, so lookups through it failed while the preferred network
reported no DNS.

Changelog: DNS lookups no longer fail on networks that report no DNS servers
2026-10-02 21:10:36 +08:00
chen08209 65176f6019 docs(readme): rewrite the README in both languages
Open with the project links and a feature list checked against the app,
list the downloads per platform, document link imports and Android
automation, and describe what a source build needs today. The Linux
note now names the runtime tray library and applies only to the
AppImage and .rpm, since the .deb declares its dependencies, and the
Android build no longer asks for an ANDROID_NDK variable.

The preview mockups keep their frames and now show the current UI: the
sidebar, the reworked dashboard and the floating dock.
2026-10-02 21:10:36 +08:00
chen08209 6041cd9289 docs(site): add a GitHub Pages site with a download guide and changelog
The page picks the build for the visitor's platform and architecture,
lists each release file with its size and SHA-256 digest, and renders
CHANGELOG.md with search. tool/build_site.sh assembles it from the
repository and the latest release, and the pages workflow deploys it
after every stable release build and on site changes to main.
2026-10-02 21:10:36 +08:00
chen08209 91e524c354 chore: exempt generated code from git whitespace checks
freezed emits lines of trailing spaces into the .freezed.dart files, which must not be edited by hand, so git diff --check failed on every change that regenerated them.
2026-10-02 20:32:42 +08:00
chen08209 786a6abca9 fix(proxies): shrink the plain group icon so it no longer fills the header
Changelog: Smaller icons in icon-only group headers
2026-10-02 20:32:42 +08:00
chen08209 1e9955dec6 fix(core,android): stop suspending the Core in Doze
The suspended tunnel rejected the traffic Doze still lets through, which
kept FCM from reconnecting, and the re-probe on resume tested every node,
select groups included.

Changelog: Push notifications reconnect in Doze
2026-10-02 20:32:42 +08:00
chen08209 e131675bdf feat(editor): move code_forge into the repository and add snippets
Changelog: Editor word wrap and snippets
2026-10-02 20:32:42 +08:00
chen08209 e62afb784a feat(dashboard): add cards and an animated, editable grid
Changelog: More dashboard cards
2026-10-02 20:32:42 +08:00
chen08209 8378c7081c feat(views): rework the proxies and scan pages, lay out records by their parts and unify search
A queued delay test shows a static clock until the task pool runs it, so
only the running tests animate while the list scrolls.

Changelog: Hide timed-out nodes and search every list
2026-10-02 20:32:42 +08:00
chen08209 841d6035d5 feat(ui): add a sidebar, floating dock, snap sheets and drag to go back
Changelog: Sidebar, floating dock and drag to go back
2026-10-02 20:32:41 +08:00
chen08209 ee6a49713a feat(ui): draw the app's own glyphs and redraw the theme page
Drops the Material compatibility bridge and the icon font.

Changelog: New app glyphs, empty states and theme page
2026-10-02 02:51:32 +08:00
chen08209 46fb7fee04 feat(overwrite): add app-level providers, scripts and validated custom proxies
Providers and custom proxies stay behind feature flags.

Changelog: Invalid override warnings and rule presets
2026-10-02 02:51:32 +08:00
chen08209 a390a42032 feat(config): log DNS queries, override only picked keys and rework settings
TUN now defaults to the mips stack.

Changelog: DNS query log and per-key DNS/NTP overrides
2026-10-02 02:51:32 +08:00
chen08209 bee8f24269 fix(backup,database): stage restores and enforce foreign keys
Changelog: Restores never apply halfway
2026-10-02 02:51:32 +08:00
chen08209 8068e686a3 feat(core): check services and the outbound IP through the Core
Changelog: Proxied service checks and outbound IP risk
2026-10-02 02:51:32 +08:00
chen08209 1e97716636 feat(platform): move desktop windows to plugins/window and rework the tray
plugins/window replaces the window_manager fork on every desktop.

Changelog: Tray hotkeys, node delays and a delay test
2026-09-30 11:39:58 +08:00
chen08209 2a2522bbc6 docs(agents): correct stale facts and sharpen skill triggers 2026-09-30 11:39:58 +08:00
chen08209 12403b063a build: target Android SDK 37, use Rust 1.98.1 and add a safe mode run
Moves to AGP 9.4.1 and Gradle 9.8.0; Rust 1.98.1 lets macOS 27 load
rust_api. Android 17 treats the tun subnet as local network, so starting
the VPN asks for that permission and falls back to gvisor without it.
Signed release builds upload native symbols to Crashlytics.
2026-09-30 11:39:58 +08:00
chen08209 c7be7023d3 docs(readme): replace the demo gifs with a desktop and mobile device mockup 2026-09-17 09:50:07 +08:00
chen08209 7c61c90ac2 chore(release): v0.8.98 2026-09-14 10:40:41 +08:00
chen08209 c5bf5bd487 fix(resources): refresh geo file info once the core finishes updating
The file info was re-read as soon as the update RPC returned, before the
core had finished writing the file, so the list kept showing the old size
and time. Refresh when the updating key clears instead, which also covers
automatic and core-initiated updates.

Changelog: Refresh the geo file size and time after an update finishes
2026-09-14 10:40:15 +08:00
chen08209 60f371afd8 fix(core): keep the IPC connection while a half-written frame waits on a suspended host
Windows Modern Standby suspends the app while the Helper's Core keeps
running, so a frame that was half written when the host stopped draining
timed out and closed the connection, which ended the Core with it. A
timed-out write on a half-written frame is now resumed for as long as the
stall lasts, and only a hard error still closes. go-winio reports the
expiry as its own ErrTimeout, so the check goes through Timeout().

Changelog: Keep the core running while Windows sleeps with the app suspended
2026-09-14 10:40:15 +08:00
chen08209 db03880579 chore(release): v0.8.97 2026-09-10 13:26:09 +08:00
chen08209 d0b1562e1d ci: split the test job into parallel jobs and cache gradle and cargo
Run Dart, plugin, Go, Android and Rust checks as separate jobs so the
critical path is the longest one instead of their sum. Android unit
tests exclude the Flutter compile tasks, which used to run the native
build hooks for every debug ABI. Gradle and cargo outputs are cached.
Release builds keep the master channel: stable publishes no Windows or
Linux arm64 SDK archive, so the action must clone the SDK there.

Changelog: skip
2026-09-10 12:43:00 +08:00
chen08209 26cfbaf456 refactor(ui): migrate to material_ui and rework views, widgets, empty states, and localization
Rebuild views, widgets, and pages on material_ui with typed enum labels
replacing runtime Intl.message lookups, and retranslated ja/ru plus
polished en/zh arb files. Includes the MATCH-TARGET rule placement
option for profile overwrites, animated connection list changes, full
proxy chains on connection and request items, a floating time hint
beside the scrollbar, FAB-aware list insets, and anchored paused log
and request lists while the buffer trims.

The basic configuration page is grouped into Inbound, Authentication,
and Other sections, with the credentials block shown only while
authentication is on and blank credentials rejected after normalizing.
Selection sheets open scrolled to the current selection, scroll bars
stay out of blurred sheet headers and show on mobile, connections sort
by total traffic and the list no longer crashes when a row leaves before
it grows in, and the Android notification stop button has a switch.

Empty states get semantic illustrations on Material shapes, and
NullStatusSwitcher crosses between an empty state and its content with a
fade-through and a fast exit instead of a hard cut. NullStatus scales its
illustration in and slides the label, description, and action up in a
stagger, the enter boxes honor the reduced-motion setting without running
their controllers, and an empty state built while its route is still
entering skips the entrance the route already animates. The custom rule
editor's no-resolve and src switches write back through the rule
provider, its delete action works, and long type, rule-set and target
names clip with a tooltip.

Changelog: Rework the app UI and refresh the localization
2026-09-10 12:43:00 +08:00
chen08209 aaf934c2d1 refactor(app): rebuild core client, providers, models, database, managers, and window handling
Rework the application layer end to end: core IPC client, providers,
models, database, managers, and shared utilities, with riverpod 3.4.2
and freezed 4.0.1. Keep a rejected profile selected, push the empty
config to Core when a profile fails to build, and fall back to the
default activity icon for package icons.

Add opt-in proxy authentication that injects credentials into the
generated profile and the runtime config, force-clears profile-provided
skip-auth-prefixes so loopback stays covered, sends the credentials with
the app's own proxied requests, and withholds the Android VPN system
proxy while credentials are set. HelperCoreLease treats an unreachable
Helper as confirmation once the OS reports the Core pid gone, and the
lifecycle reconcile retries a parked stop, so the Core comes back after
the Helper service dies. Launch arguments seed LinkManager so a clash://
link raises a silently started window, the access control app list is
fetched on every page entry, the desktop header matches a standard title
bar height, and the tray icons ship as resolution-aware assets scoped per
platform. The version moves to 0.8.97+2026090101.

Rule.parse mirrors mihomo's ParseRulePayload: the type is
case-insensitive, MATCH rules keep no content field, a two-field rule is
payload without a target except for comma-payload types whose lone field
is the target, params are matched whole, and the wildcard and
REMATCH-NAME types the core accepts get their own actions instead of
collapsing into DOMAIN. Window routes show, hide and toggle through one
serialized queue and defers the macOS accessory activation switch until
a second after the last regular switch, so hotkey bursts no longer leave
stray Dock icons. The hotkey manager registers through rust_api instead
of hotkey_manager, the window header restyles the caption buttons after
the native Windows title bar, and the proxies tab keeps its tab
controller alive while the empty state animates in. Bootstrap reads the
Android dynamic palette from the plugin channel without the deprecated
CorePalette, dynamic_color moves to its material_ui release,
navigator_resizable 3.1.0 fixes an assertion when popping
mid-transition, reorderable_grid goes, and the SDK floor rises to 3.10.

Changelog: Rework the app layer and window handling, and add proxy authentication
2026-09-10 12:43:00 +08:00
chen08209 c0fcbc03ee feat(desktop): rework the windows, linux, and macos runners, packaging, and native build
Update the runner projects for the reworked plugins. The Windows runner
asks the plugin for the running window before booting a second instance
and forwards a clash:// link through app_links; the Linux runner routes a
relaunch to the primary through GApplication; the macOS delegate forwards
applicationShouldHandleReopen to the plugin. The Linux desktop entries
claim the clash, clashmeta, and flclash schemes, and the rpm spec stops
stripping the Core whose hash the Helper validates.

The runner projects consume the setup build hook's artifacts: macOS drops
the CocoaPods integration and its script phase, pins release ARCHS to the
host, and stages the Core after the hook may have rewritten it; Windows
and Linux copy the Core, Helper and manifest from the CMake install
rules, and a Debug install on Windows stops a Helper that holds its exe
open, matched by executable path so a registered release Helper keeps
running. The deb depends on the appindicator runtime library instead of
its development package, and the keybinder dependency and README lines
go with hotkey_manager.

Changelog: Rework the desktop runners, packaging, and native build
2026-09-10 12:43:00 +08:00
chen08209 ae29f38f6b feat(android): rework the VPN service, tile, lifecycle arbitration, and native build
ServiceState serializes start, stop, and restart intents so the latest
request wins, and switching between VPN and proxy mode tears the previous
service down the way stop() does before binding the next one, so a
system-started VpnService no longer outlives the switch. The package
resolver drops its cache on package add, replace, and remove broadcasts
and notifies Flutter, and the persistent notification can omit its stop
action through an application setting synced over the shared state.

The :core module takes the NDK clang directory and API level from the
setup build hook input instead of ANDROID_NDK and a hardcoded API 21, and
derives its ABI list from the target-platform property so a single-ABI
build no longer links ABIs the hook did not produce.

Changelog: Rework the Android VPN service and lifecycle handling
2026-09-10 12:42:59 +08:00
chen08209 adf715f8ac refactor(plugins): rework desktop plugins and add rust_api, the helper service, and build hooks
Replace window_ext with a first-party tray plugin that loads multi-size
ICO and resolution variants, rework setup, proxy, and wifi_ssid, and add
the Rust API plugin and the desktop Helper service.

The Helper now also serves Linux: setup installs it as a systemd unit on
demand from authorizeCore(), refusing a Helper binary that is not
root-owned or a unit installed for another UID, and it listens on
/run/flclash/helper.sock filtered by SO_PEERCRED against the installing
UID. /start requires the Core address to be a socket owned by the owner
UID, the listener survives accept() errors, and SIGTERM reaches the Core
teardown. Hosts without systemd keep the setuid Core, and an AppImage
reports TUN authorization as unavailable. The Dart-side IPC socket is
bound at 0600 and admits only a peer whose effective uid is the app's
own or root, since the Core connects as root under setuid or the Helper.
Override scripts resolve a main declared with const or let.

rust_api upgrades flutter_rust_bridge to 2.13.0 and builds through
Native Assets: the vendored Cargokit harness and the per-platform FFI
scaffold give way to a Dart build hook on flutter_rust_bridge_hooks, the
crate pins its toolchain and targets in rust-toolchain.toml, and the hook
exports the NDK libclang directory that rquickjs's bindgen needs on
Android, probing lib and lib64 and failing with a clear message when
neither holds it. It also replaces hotkey_manager: the crate wraps the
tauri global-hotkey crate, keys arrive as Flutter USB HID usages,
registration runs on the thread each platform binds it to (a
message-loop thread on Windows, the main queue on macOS), presses stream
back to Dart as action ids, and Linux stays X11 only and reports a
missing DISPLAY instead of registering silently.

setup builds the Go core and the Rust Helper through a Dart build hook in
place of the buildkit glue. Flutter runs the hook before every platform
build and flutter test, once per target architecture; it constructs
CoreBuilder from the setup_hooks package, which turns the hook input into
a BuildRequest, compiles the Core and, on Linux and Windows, the Helper
and manifest, and reports the files it read and the directories it wrote
as hook dependencies. setup_hooks keeps a fingerprint cache under
.dart_tool/setup_build_cache keyed on go list -deps inputs, module files,
build config, harness sources and toolchain versions; builds run in a
staging directory and move into place on success, and everything the
hook prints is mirrored into hook.log, which keeps appending when a
rotation fails. Hook failures reach the runner as BuildError or
InfraError, filesystem errors included.

Changelog: Rework the desktop plugins and add the Helper service and Rust bridge
2026-09-10 12:42:59 +08:00
chen08209 c6eaa0a68b refactor(core): rebuild the Go core IPC, hub, and ownership model
Rebuild the IPC server, hub, tiered message queues, and the file
ownership reclaim a setuid or Helper-spawned Core performs, and move the
Clash.Meta fork forward. UpdateParams carries the authentication users
the app injects at runtime.

Changelog: Rework the core IPC and process lifecycle
2026-09-10 12:42:59 +08:00
chen08209 96fcb9ab5e chore(agents,tool): rebuild agent config, lint gates, CI, and release tooling
Rework .agents/, Claude/Codex config, CI workflows, lint gates and
lint-rule tests, and the release/changelog tooling. The 5% comment
density gate stays diff-scoped inside git hooks, where GIT_DIR outranks
the -C the gate uses to locate the repository. The tray icon generator
writes multi-size ICO files from the SVG sources into the per-platform
tray asset directories. The custom code-review subagents are gone: the
built-in /code-review never dispatched them, and the subagent model now
lives in a user-level setting outside the repository.

The Makefile, build_config.yaml and CI hand the Core and Helper builds to
the setup build hook, so the buildkit launchers go, and setup.dart
packages AppImage and rpm on arm64 with flutter_distributor pinned to the
fork tag that normalizes packaging permissions.

Changelog: skip
2026-09-10 12:42:52 +08:00
chen08209 62addf738a Update changelog 2026-08-17 07:29:13 +00:00
1196 changed files with 188328 additions and 41092 deletions
+7 -3
View File
@@ -4,11 +4,12 @@ Use the smallest durable surface that matches the job.
## Surfaces
- `AGENTS.md`: auto-loaded repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
- `AGENTS.md`: repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
- No `CLAUDE.md`: Claude Code v2.1.277+ loads `AGENTS.md` at startup only while no `CLAUDE.md`, `.claude/CLAUDE.md`, or `CLAUDE.local.md` sits in the working directory or above it, so adding one silently replaces `AGENTS.md` for Claude. Sessions that cannot load `AGENTS.md` directly (third-party providers, telemetry disabled) need an uncommitted `CLAUDE.local.md` containing `@AGENTS.md`. The one nested `CLAUDE.md`, in `plugins/rust_api/`, imports the root `AGENTS.md` for the same reason.
- `.agents/*.md`: human- and agent-readable reference docs linked from `AGENTS.md`. Use these for detailed project context, commands, architecture, and conventions.
- `.agents/skills/*/SKILL.md`: repo-scoped Codex skills. Use these for repeatable workflows that should be discoverable by name and description.
- `.agents/skills/*/SKILL.md`: repo skills shared by Codex and Claude Code. Use these for repeatable workflows that should be discoverable by name and description; `.claude/skills/` holds one symlink per skill so Claude Code finds the same files.
- `.codex/config.toml`: trusted project Codex settings such as MCP, hooks, sandbox, approval, or model defaults.
- `.codex/rules/*.rules`: command permission rules for running commands outside the sandbox. Do not mix these with coding conventions in `.agents/rules.md`.
- `.codex/rules/*.rules`: command permission rules for running commands outside the sandbox. None exist yet. Do not mix these with coding conventions in `.agents/rules.md`.
- `.codex/hooks.json` or `.codex/config.toml` hooks: lifecycle automation such as prompt checks, command validation, or final verification gates.
## Placement Rules
@@ -17,6 +18,9 @@ Use the smallest durable surface that matches the job.
- Put detailed explanations in `.agents/*.md` and link them from `AGENTS.md`.
- Put reusable task workflows in `.agents/skills/<skill-name>/SKILL.md`.
- Put mechanical enforcement in linters, tests, hooks, or `.codex/rules`; do not rely on prose when tooling can enforce the rule.
- Split a rule into the part that can be counted and the part that cannot, and enforce them differently. A rule resting entirely on judgment gets decided at the keyboard, at the moment the exception looks most justified; the comment policy is the worked example, where "is this comment useful" stays prose and review while "are these comments too frequent" became a measured gate. Find the countable half before writing more prose.
- Restate the one-line form of a rule in every context that must obey it, and keep its explanation in one `.agents/*.md` file that the others link to. Prefer a runnable check to either: a gate an agent trips before reporting done outperforms a paragraph asking it to have been careful.
- State a gate's real force. Enforcement differs per tool, and a rule that claims uniform coverage it does not have is worse than one that names the gap.
- Keep user-specific preferences out of the repository. They belong in user-level Codex configuration or user-level skills.
## Skill Authoring Rules
+811 -113
View File
File diff suppressed because it is too large Load Diff
+214 -28
View File
@@ -17,26 +17,49 @@ dart setup.dart windows
dart setup.dart android
```
Build only the Go core and skip Flutter packaging:
The Go core and the Rust helper build automatically: Flutter runs
`plugins/setup/hook/build.dart` on every `flutter build` and `flutter test`,
and the hook drives `CoreBuilder` from the `setup_hooks` package in
`plugins/setup/setup_hooks/`. Android builds take the NDK from the C compiler
Flutter hands the hook; no `ANDROID_NDK` variable is needed.
Artifacts land in `libclash/`. The hook reruns only when Go, Rust, or
`setup_hooks` inputs change; the fingerprint cache lives in
`.dart_tool/setup_build_cache/`. To force a rebuild, delete that directory:
```bash
make core-macos
make core-linux
make core-windows
make core-android
rm -rf .dart_tool/setup_build_cache
```
Pass `ARCH` or `TARGET_PLATFORM` through `make` when needed, for example:
Flutter hides the hook's output on success, so to see why the Core was or was
not rebuilt read `.dart_tool/setup_build_cache/hook.log`; each invocation starts
with a `===` line carrying its timestamp and target.
```bash
make core-macos ARCH=arm64
make core-android TARGET_PLATFORM=android-arm64
The hook runs for `flutter test` and for `dart run` of any root-package script
too, so a pure Dart test run needs the Go toolchain, and on Linux and Windows
also `cargo` and `rustc` — the Helper fingerprint shells out to both even on a
cache hit. The hook protocol carries no build mode and no caller, so the only
switch is the user-define in the root `pubspec.yaml`:
```yaml
hooks:
user_defines:
setup:
build_assets: true # false turns the Go core and Helper hook into a no-op
rust_api:
build_assets: true # false does the same for the Rust library
```
Core builds use setup's input fingerprint cache. Pass `FORCE=1` to bypass it,
for example `make core-macos ARCH=arm64 FORCE=1`.
The Makefile wraps `plugins/setup/buildkit/run_build_tool.sh`; prefer the `make` entry points unless debugging the build tool itself.
CI flips `setup` to `false` with `yq` before `dart run tool/changelog.dart` and
`flutter test`, and restores the file afterwards; no test loads the Go core. `rust_api`
stays on there because the editor tests in `test/plugins/code_forge/` load the Rust
library the hook builds; the release-notes job, which runs no tests, flips both.
Never commit `false`: a build with it set stages whatever `libclash/` already
holds and bundles no Rust library, which is why `setup.dart` refuses to package
while it is set. Locally, `false` is worth setting for a Dart-only test loop,
but the hook cache keys on the user-define, so the first build afterwards runs
the hooks again. The editor tests still need the Rust library: keep `rust_api`
on, or point `FRB_DART_LOAD_EXTERNAL_LIBRARY_NATIVE_LIB_DIR` at a directory that
holds a `rust_api` library built from the same sources.
## Flutter Development
@@ -50,6 +73,23 @@ flutter test
Use `flutter test`, not `dart test`, because models pull in Flutter types.
A development build takes over the host exactly like a release: it creates the TUN device, sets the system proxy, on
macOS rewrites the system DNS, registers itself as the login item, and on Windows and Linux starts its Core through the
shared privileged Helper, which stops the Core an installed FlClash is running. Only the data directory is kept apart,
and only for some builds: a macOS debug build is `com.follow.clash.debug` and an Android debug build `com.follow.clash.dev`,
while profile and release builds, and every build on Windows and Linux, share the directory and preferences of the
installed app. Safe mode keeps all of the host changes off and never opens the Core's listeners or external
controller, so it does not compete with the installed app for the mixed, DNS, RESTful API and other loopback ports:
```bash
flutter run --dart-define-from-file=env.json --dart-define=SAFE_MODE=true
```
`.run/main_safe_mode.run.xml` is the same launch for IntelliJ, and `Flutter App (Safe Mode)` in `.zed/debug.json` (debugger) or `flutter run (Safe Mode)` in `.zed/tasks.json`
(plain run) for Zed. The build is bannered `SAFE MODE`, the tray shows the
`status_4` bug icon with a menu of only show and exit and no traffic title, and the settings keep their values, so
nothing has to be flipped back before a normal run. See the safe mode section of `architecture.md` for the owners that consult the flag.
## Code Generation
Run code generation after modifying models, providers, or database schema:
@@ -65,12 +105,37 @@ Code generation covers:
- Models through `freezed` and `json_serializable`.
- Database tables through `drift_dev`.
The Dart-Rust bindings in `plugins/rust_api` are generated by `flutter_rust_bridge_codegen` from
`plugins/rust_api/flutter_rust_bridge.yaml`; rerun it from that directory after changing anything under
`rust/src/api/`:
```bash
cd plugins/rust_api && flutter_rust_bridge_codegen generate
```
Generated output paths, configured in `build.yaml`:
- `lib/models/generated/*.g.dart`, `*.freezed.dart`.
- `lib/providers/generated/*.g.dart`.
- `lib/database/generated/*.g.dart`.
Tray and Windows app icons are generated, not hand-edited. `assets_source/images/icon/*.svg` is
the source of truth; the script needs `rsvg-convert` (librsvg) on `PATH`:
```bash
dart run tool/generate_status_icons.dart
```
It writes the tray PNGs with Flutter `2.0x/`–`4.0x/` resolution variants to `assets/images/tray/unix/`,
multi-size tray `.ico` files to `assets/images/tray/windows/`, and `windows/runner/resources/app_icon.ico`
from `assets/images/icon.png`. macOS draws its menu bar icon as a template, so it skips the ringed color
icons, whose logo shrinks to a small glyph once only alpha shows: `status_1` renders the bare logo from
`assets_source/images/icon/glyph.svg` and `status_4` reuses `status_4.svg`, both recolored to black into
`assets/images/tray/macos/`; the sources keep their colors. `glyph.svg` is also the geometry of the Android notification drawables
`android/service/src/main/res/drawable/ic.xml` and `ic_service.xml`; change them together. `pubspec.yaml` declares the three tray directories with `platforms:` so each
build only bundles the format its tray loads; a new status icon needs a source SVG and an entry in the
script's `statusIconNames`, nothing in `pubspec.yaml`.
## Testing
Tests use `package:test/test.dart` for pure Dart logic and `flutter_test` for provider and widget tests. `mocktail` is the mocking framework.
@@ -87,7 +152,14 @@ flutter test test/setup_test.dart
flutter test plugins/proxy/test/proxy_test.dart
```
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory. Native plugin tests under platform folders are not run by `flutter test`.
`plugins/setup/setup_hooks/` is a pure Dart package and is tested with `dart test` from its own directory, which is
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
run of `plugins/setup` would execute the build hook itself.
Native plugin code under `plugins/*/{linux,macos,windows}` is compiled by the CI `desktop` job, which runs
`flutter build <platform> --debug` on every push; `tool/check_plugins.sh` only analyzes and tests the Dart side.
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`.
For the current Core/service architecture, useful focused checks are:
@@ -131,32 +203,146 @@ cargo test --manifest-path services/helper/Cargo.toml --features windows-service
```
The last command requires Windows for meaningful service coverage. Native Android lifecycle edits should at minimum
compile the modules they touch; use JDK 17 in this checkout:
compile the modules they touch, with the JDK 17 that CI installs. On macOS the Homebrew `openjdk@17` keg is not
registered with `java_home`, so export it by prefix:
```bash
cd android
JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew :service:compileDebugKotlin
JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew :app:compileDebugKotlin
export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home" # elsewhere: any JDK 17
./gradlew :service:compileDebugKotlin
./gradlew :app:compileDebugKotlin
```
Always-on VPN entry, system VPN revoke, actual permission UI, and rapid device start/stop still require Android device or
emulator validation; Kotlin compilation cannot prove those system callbacks.
## Verify
## Changelog And Release
The tag-triggered release workflow runs these root-package checks in order:
The changelog is derived from Conventional Commits by `tool/changelog.dart` and written to two committed files:
`CHANGELOG.md` for readers and `changelog.json` for the renderers. See `.agents/rules.md` for the `Changelog:` trailers
that decide the wording.
The app ships no changelog of its own. `render release` appends the released version as JSON inside an HTML comment
(`<!-- flclash:changelog:json … -->`), so the release body GitHub already returns to `checkForUpdate` carries the
notes shown in the update dialog. `parseReleaseChangelog` reads that block and falls back to the English
bullets when a release predates it.
```bash
flutter pub get
flutter analyze --no-fatal-infos
flutter test --reporter expanded
dart run tool/changelog.dart verify # what CI checks
dart run tool/changelog.dart release --version 0.8.96
dart run tool/changelog.dart build --unreleased # changelog.json only, includes untagged work
dart run tool/changelog.dart render release --out release.md
dart run tool/changelog.dart render telegram --out telegram.md
```
Run `flutter analyze` locally before committing when practical.
Releasing a stable version, in order:
The workflow runs only for `v*` tag pushes; pull requests do not trigger it.
```bash
tool/bump_version.sh all
dart run tool/changelog.dart release --version 0.8.96
git commit -am "chore(release): v0.8.96"
git tag v0.8.96
git push origin main && git push origin v0.8.96
```
Push the tag by name. Every release tag here is lightweight, and `--follow-tags` carries annotated tags only: it skips a
lightweight one silently, so the branch lands, the tag does not, and the release workflow never fires.
`tool/release.sh` drives both paths so the ordering below cannot be got wrong by hand. It resolves the version (bumping
the patch when pubspec still names an already tagged one), prints the notes the tag would ship, and only pushes with
`--push`:
```bash
tool/release.sh pre --dry-run # plan and notes, changes nothing
tool/release.sh pre --push # bump, tag vX.Y.Z-pre.N, push
tool/release.sh stable --push # changelog, chore(release) commit, tag, push
```
The release commit comes before the tag on purpose: the generated wording is reviewable in the diff before it ships, and
the tag is what `render release` reads. CI never writes back to the repository; it only runs `verify`. Wording in
`changelog.json` may be edited by hand as long as no derivable entry disappears and every entry still points at a commit
inside that version's range.
Entries at or below `v0.8.96` are frozen: they predate the pipeline, live under the `<!-- changelog:frozen -->` marker
in `CHANGELOG.md`, and are never regenerated.
`verify` compares a version only when its tag is reachable from `HEAD`, because that is the same scope the builder walks
(`git tag --merged`). A branch cut before the newest release cannot derive that version at all, so `verify` names it as
skipped and moves on instead of reporting drift that does not exist. Checking mere tag existence is what made every such
branch fail on an unrelated release.
Prerelease tags (`v0.8.96-pre.N`) skip the release commit, and CI renders their notes with `build --unreleased` for the
Telegram post. They publish no GitHub release, so the update dialog never sees them. `build --unreleased` reads the
version from `pubspec.yaml` rather than the tag, so the patch has to be bumped before the first `-pre.N` of a cycle:
while `v<pubspec version>` is still tagged it refuses to collect anything and the release job fails.
### Website
`site/` is the GitHub Pages site: a static page that renders `CHANGELOG.md` and the download links in the browser, and
works as is for local previews. `tool/build_site.sh [dir]` assembles it with the preview images, the app icon,
`CHANGELOG.md` read at the latest release tag, and a `release.json` of asset names, sizes and digests fetched through
`gh`. Without `gh` access the page falls back to the newest version in `CHANGELOG.md` and lists the files without sizes.
It then runs `tool/render_site.mjs` (Node), which writes the English page and a `zh/` copy for search engines: page
text, the first changelog releases, canonical and `hreflang` links, JSON-LD and `sitemap.xml`, all from the strings,
changelog parser and release markup in `site/assets/shared.js`. `SITE_URL` overrides the published URL these point at.
On the built site each URL keeps its language: the language button links to the other page and carries the section in
view, the chosen platform and the changelog search. Only the English root sends readers who chose or prefer Chinese on
to `zh/`, unless its URL carries `?lang=en`: the button on `zh/` links there with it, so getting back to English never
depends on browser storage. A project site cannot serve the `robots.txt` that would announce the sitemap, so submit
`sitemap.xml` in Google Search Console and Bing Webmaster Tools once.
```bash
tool/build_site.sh && python3 -m http.server -d build/site
```
The page's look is deliberate: low-saturation flat colour, no blur, glow, glass or soft shadow, and motion that follows
the logo's 30° lattice. `site/assets/field.js` draws the hero's capsules on that lattice with the glyph's own
proportions, and the slanted wipes, the dot-to-bar marks and the theme transition reuse the same angle. Text that sits
inside the hero has to be listed in `wireField` in `app.js`, or the capsules run behind it.
`.github/workflows/pages.yaml` deploys it after every successful stable tag build and on pushes to `main` that touch the
site, in the public repository only; its Pages source has to be set to GitHub Actions once. The file names come from the
release matrix in `build.yaml`, so a renamed artifact needs the same change in `buildsFor` in `site/assets/app.js`.
## Verify
In the public repository every branch push runs the `dart` job; in the private
`origin` every check job runs only when started by hand (`workflow_dispatch`).
The `dart` job performs these root-package checks in order:
```bash
bash tool/check_commit_msg_test.sh
bash tool/check_comment_density_test.sh
flutter pub get
dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart
flutter analyze --no-fatal-infos
dart run tool/changelog.dart verify # main and tags only
flutter test --reporter expanded --coverage
dart run tool/check_coverage.dart coverage/lcov.info 75
```
Run `flutter analyze` before committing; CI fails on anything it reports.
Release builds run only for `v*` tag pushes; pull requests trigger nothing.
Root analysis excludes `plugins/**`, and root tests do not discover nested
plugin packages, so CI also validates local Flutter packages, the setup build
tool, the Go wrapper, and Rust components from their own package directories. A
separate Windows runner compiles and tests the helper's `windows-service`
feature before release builds can start.
plugin packages, so parallel jobs validate the rest from their own package
directories: `plugins` (local Flutter packages and the setup build tool), `go`
(the Core wrapper, plus an NDK-backed vet of the Android files), `android`
(JVM unit tests for `:common`, `:service` and `:app`, with the Flutter compile
tasks excluded so no native build hook runs), `rust` (both crates), and a
Windows runner for the helper's `windows-service` feature. A `desktop` job also
compiles a debug build per desktop platform. Release builds run only in the
public repository and start once every job except `desktop` passes.
`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script.
It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
`flutter test` wherever `test/*_test.dart` exists. Adding a plugin package needs
no workflow edit; enumerating packages by hand in the workflow is what
previously left `plugins/tray` unanalyzed and untested.
## Worktree Tooling
```bash
bash tool/worktrees.sh list # every worktree with owner tool and dirty/clean state
bash tool/worktrees.sh prune # remove clean worktrees; add --force to drop dirty ones too
```
+73
View File
@@ -0,0 +1,73 @@
# Flutter Upgrade Checklist
Read this before changing `FLUTTER_VERSION` in `.github/workflows/build.yaml` or `.fvmrc`. Each entry is a
workaround tied to a Flutter bug: what it does, where it lives, and what has to be true before it can go. Remove an
entry together with its workaround; add one whenever a new workaround is pinned to the SDK.
## Stable cadence
Flutter ships a stable release about once a quarter, three minor versions apart (3.41 in February, 3.44 in May, 3.47
in August 2026). The two minors in between (3.48, 3.49) are beta only, so a fix that lands on `main` reaches stable
in the next multiple, 3.50, expected around mid-November 2026. Hotfix releases (3.47.x) carry only cherry-picks
listed in the SDK's `CHANGELOG.md`.
## Impeller disabled on Intel macOS release builds
- **Bug:** [flutter/flutter#191538](https://github.com/flutter/flutter/issues/191538). From 3.47, when Impeller
became the default desktop renderer, Intel (x86_64) Macs flicker across the whole window while scrolling or
animating, and text can render garbled. Apple Silicon is not affected.
- **Fix upstream:** landed on `main` before 2026-08-25 and verified on `3.48.0-1.0.pre-404`; it is not in any
3.47.x hotfix (checked through 3.47.5).
- **Workaround:** the release job for `macos-15-intel` (`arch: amd64`) runs
`plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist` before `dart setup.dart`, so only the Intel
DMG falls back to Skia. The checked-in `Info.plist` does not set the key, so local builds and the arm64 DMG keep
Impeller. The macOS embedder reads the key at launch in `FlutterDartProject.mm` (`enableImpeller`), release
builds included.
- **Side effect:** Skia on Intel renders blur, shadows and text slightly differently; judge Intel-only visual reports
against Skia, not Impeller.
- **Remove when:** `FLUTTER_VERSION` moves to a stable that contains the fix (3.50 or later, or a 3.47.x whose
changelog lists #191538). Before dropping the step, build the Intel DMG without it and scroll the proxies and
dashboard pages on an Intel Mac with its integrated GPU driving the built-in display; an external display can
switch to the discrete GPU and hide the flicker.
## Windows view visibility forwarded by the runner
- **Bug:** the Windows embedder's `WindowsLifecycleManager` counts the Flutter view as a window of its own and keeps
it visible until the view is sized to zero. Hiding the top-level window never resizes the view, and the `window`
plugin keeps it sized while minimized, so the app was never told `hidden`: animations kept rendering in the tray
and `appVisibleProvider` stayed true.
- **Workaround:** `WindowPluginForwardVisibility` in the `window` plugin's
`windows/include/window/window_plugin_runner.h` hands the top-level window's `WM_SHOWWINDOW` and `WM_SIZE` to
`ProcessExternalWindowMessage` for the view's HWND, so the view shows and hides with its window.
`FlutterWindow::MessageHandler` in `windows/runner/flutter_window.cpp` calls it before `HandleTopLevelWindowProc`;
the Flutter runner template has no such call, so keep it when regenerating the runner.
- **Remove when:** the engine hides the view together with its top-level window. Check with the proxy running and
the window focused: hiding it to the tray and minimizing it must each log `AppLifecycleState.hidden`.
## macOS activation resumes a hidden window
- **Bug:** `handleWillBecomeActive` in the macOS engine's `FlutterEngine.mm` resumes when any window in
`NSApp.windows` is visible, and the tray's `NSStatusBarWindow` always is. Activating the app while its window was
minimized or hidden, such as Cmd-Tab back to a minimized window, reported `resumed`, and the next deactivation
`inactive`, so frames ran until the window was shown again.
- **Workaround:** the `window` plugin observes will-become-active and will-resign-active and, once the engine has
answered, sends `AppLifecycleState.hidden` on `flutter/lifecycle` while its window is not visible
(`applicationActivityChanged` in `WindowPlugin.swift`). A window brought back without another activation change
would then wait on an occlusion notification macOS does not always post, keeping its last frame, so `show` and a
deminiaturize hand the state back (`windowBecameVisible`).
- **Remove when:** the engine ignores status item windows on activation. Check without the workaround: minimize the
window, switch to another app, then Cmd-Tab back without restoring it; the log must stay at
`AppLifecycleState.hidden`.
## Linux minimize on Wayland reads as inactive
- **Bug:** the Linux engine reports `hidden` for `GDK_WINDOW_STATE_ICONIFIED`, but xdg-shell has no minimized state,
so GTK 3 never sets it on Wayland. A minimized window reported only its focus loss, as `inactive`, and kept
rendering at the display rate (GNOME 46).
- **Workaround:** the `window` plugin's Linux `minimize` records the time, and a focus loss within 500 ms of it sends
`AppLifecycleState.hidden`. Its `window-state-event` handler is connected after the engine's, so that message
follows the engine's `inactive`; the compositor focuses the window when it restores it, which the engine reports as
`resumed`. A minimize the compositor starts itself (Super+H, the dock) is not covered.
- **Remove when:** the Linux engine reports minimized windows on Wayland, which needs GTK 4 or xdg-shell's
`suspended` state. Check in a GNOME Wayland session without the workaround: minimizing from the caption button
must log `AppLifecycleState.hidden`.
+77 -5
View File
@@ -4,22 +4,94 @@ FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with
## Version Notes
- Release CI pins Flutter 3.44.4. Local SDK may diverge, so trust the CI
version as the source of truth for release builds.
- Dart SDK constraint: `>=3.8.0 <4.0.0`.
- Flutter stays on the 3.47.x line. Release CI pins 3.47.4 and is the source
of truth for release builds; `.fvmrc` and local SDKs may sit on another 3.47
patch. The editor in `plugins/code_forge` came from `code_forge` 10.14.0, which
needs Dart 3.13.2, so the CI pin cannot go below Flutter 3.47.2.
- Before moving the pin, walk `flutter-upgrade.md`: it lists the workarounds tied to
the current SDK, such as Impeller disabled on the Intel macOS release build.
- The Dart SDK lower bound in `pubspec.yaml` sets the package language
version. `flutter analyze` excludes `lib/**/generated/**`, so a raise is
proven by `flutter test`, which parses the generated code, not by analyze.
## Forked Dependencies
Two `pubspec.yaml` dependencies are pinned to a fork by commit SHA. Both forks
live under `chen08209`, the same account that owns this repository, so they are
maintained in-house rather than tracked from a third party: advancing a pin is a
local decision, and there is no external maintainer to wait on for the patch
itself. What each fork still waits on is the *upstream* fix that would let the pin
be dropped entirely, recorded below.
Each entry records what the fork changes and what has to be true before it can go
back to the published package, so a future upgrade does not have to rediscover it.
Re-verify a fork by diffing its pub cache checkout against the published version
of the same number:
```bash
diff -ru ~/.pub-cache/hosted/pub.dev/<name>-<version> ~/.pub-cache/git/<name>-<sha>
```
The `window_manager` fork (`chen08209/window_manager`, tag `v0.5.1-flclash.3`)
was replaced by the in-repo `plugins/window` package; the behaviors it carried
(work-area maximize with a hidden title bar on Windows, geometry restore on
re-map on Linux, corner preference, terminate and activate events) live there
now, see the window plugin section of `architecture.md`.
`launch_at_startup` — `chen08209/launch_at_startup`, version 0.5.1.
- Migrates `win32_registry` from `^2.0.0` to `^3.0.3`, which is a breaking rename
across the whole Windows implementation (`Registry.openPath` → `CURRENT_USER.open`,
`createValue` → `setValue`, `getStringValue` → `getString`).
- This one is not optional while it lasts: FlClash depends on `win32_registry: ^3.0.3`
directly, and upstream's `^2.0.0` constraint cannot co-resolve with it.
- Drop the fork when upstream publishes a release that accepts `win32_registry` 3.x.
`yaml_writer` — `chen08209/yaml_writer`, version 2.1.0.
- Adds `StringNode.quoteKey()` and applies it to map keys in `lib/src/node.dart`.
Upstream quotes values but emits keys verbatim, so a profile key needing quotes
is written as invalid YAML.
- Drop the fork once upstream quotes map keys by the same
`isValidUnquotedString` rule it already applies to values.
The `code_forge` fork (`chen08209/code_forge`) is frozen: its Dart code now lives
in the in-repo `plugins/code_forge` package and its Rust code in the `editor/`
module of `plugins/rust_api`, both trimmed to what FlClash uses.
`plugins/code_forge/README.md` records what was kept and what was removed;
upstream's MIT notice stays in `plugins/code_forge/LICENSE`.
## Dependency Ceilings
`flutter pub outdated` lists only upstream-held entries, so treat them as
resolved-until-the-ceiling-moves rather than as debt:
- `intl` is intentionally unbounded (`any`) and `material_color_utilities` is
resolved by the Flutter SDK; neither is a bound this repository sets.
`freezed`, the `riverpod` set, `build_runner`, `drift_dev`, `intl_utils` and
`test` all resolve against the analyzer the pinned SDK ships; none of them is
held back. When an upgrade fails version solving, record the blocker here with
what has to change upstream before the pin can move, so the next
`flutter pub outdated` run does not rediscover it.
`_initDynamicColor` in `lib/bootstrap.dart` reads the `dynamic_color` method
channel directly and decodes the palette list into a `TonalPalette`, instead of
going through `DynamicColorPlugin.getCorePalette()` and its deprecated
`CorePalette` return type. Everything downstream sees `DynamicColorSeeds`, so a
non-deprecated plugin accessor changes only that one function.
## Build Dependencies
Linux:
```bash
sudo apt-get install libayatana-appindicator3-dev libkeybinder-3.0-dev
sudo apt-get install libayatana-appindicator3-dev
```
Windows:
- GCC and Inno Setup.
- `ANDROID_NDK` env var for Android builds.
macOS:
+446 -30
View File
@@ -4,7 +4,11 @@ These are repository coding and testing conventions. Codex command permission ru
## Dart and Flutter Style
`analysis_options.yaml` enforces these non-default rules:
The lint set lives in `lint_options.yaml` at the repo root. The root `analysis_options.yaml` and every local plugin under
`plugins/*` include it, so application and plugin code are held to the same rules. Add or change a rule there, not in an
individual `analysis_options.yaml`; those files carry only their own `analyzer.exclude` entries.
`lint_options.yaml` enforces these non-default rules:
- `prefer_single_quotes: true`: always use single quotes.
- `require_trailing_commas: true`: use trailing commas in multi-line argument lists.
@@ -13,65 +17,241 @@ These are repository coding and testing conventions. Codex command permission ru
- `prefer_const_constructors: true` and `prefer_const_declarations: true`.
- `prefer_final_locals: true` and `prefer_final_in_for_each: true`.
- `always_declare_return_types: true`.
- `only_throw_errors: true`: throw an `Exception` or `Error`, never a bare `String`.
Generated directories are excluded from analysis:
Failures whose whole content is a message meant for the user throw
`MessageException` from `lib/common/exception.dart`. Its `toString()` is the bare
message, which is what `globalState.safeRun` surfaces in the dialog, so the
user-facing text is unchanged from the older `throw someMessage` idiom while the
throw stays catchable as an `Exception` and carries a stack trace. Assert on it
with `isA<MessageException>().having((e) => e.message, 'message', ...)`, not on a
raw string.
### Corner Radius
Rounded corners are superellipses (continuous corners) everywhere, never circular arcs; when a component offers
both, pick the superellipse. `test/lint/superellipse_corners_test.dart` fails on the circular APIs below in `lib/`. Use
the superellipse API at each layer:
- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`.
- Clips: `ClipRSuperellipse` instead of `ClipRRect`.
- Container decorations: `ShapeDecoration(shape: RoundedSuperellipseBorder(...))` instead of
`BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with
`clipBehavior` still clips to the shape path.
- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`.
- Ink: `InkWell.customBorder` with an `AppShape` instead of `InkWell.borderRadius`.
Passing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only
carries the corner magnitude; the rendered geometry stays a superellipse.
Pills are no exception. `StadiumBorder` joins its round caps to the straight sides with a curvature jump, while
`AppShape.full`, a superellipse at full radius, blends them, and on a square it renders the exact circle, so it serves
circles too. Material 3 defaults every button, icon button, segmented button, and search bar to `StadiumBorder`;
`ThemeData.withAppShapes` replaces that for the button themes, and a local `FilledButtonTheme` or `IconButtonTheme`
replaces the app's, so merge the ambient `XButtonTheme.of(context).style` into it, as `CommonMinFilledButtonTheme`
does. `CircleBorder` and `BoxShape.circle` are fine for something that is always square.
APIs that accept only a `Radius`, such as `ScrollbarThemeData.radius`, keep circular corners; supply the superellipse
with an enclosing clip or shape where it shows.
CI gates formatting: `dart format --output=none --set-exit-if-changed lib test
tool plugins setup.dart` runs before `flutter analyze`.
These directories are excluded from analysis:
- `build/**`
- `lib/l10n/intl/**`
- `lib/**/generated/**`
- `plugins/**`
- the platform runner directories: `android/`, `ios/`, `web/`, `windows/`, `macos/`, `linux/`
## Comments
Comments are opt-in and reserved for the few places that genuinely need one. Density is the point: every comment that
restates the code devalues the comments that carry real information, until readers skim past all of them. A file with
three comments that matter is more readable than one with thirty.
Comments are part of the code and some of them are load-bearing. What this repository limits is the useless and the
frequent. Every comment that restates the code devalues the ones carrying real information, until readers skim past all
of them — a file with three comments that matter is more readable than one with thirty.
### Writing Comments
The two halves are enforced differently, because only one of them can be counted. Whether a comment is useless is a
judgment, and it lives in the rules below and in review. Whether comments are too frequent is arithmetic, and
`comment-density` measures it.
- Never add a comment on your own initiative. This covers explanatory, narrative, TODO, section-divider, and
documentation comments, in Dart, Kotlin, Swift, Go, Rust, YAML, Gradle, and any other file you touch.
- Never annotate line by line or statement by statement, and never restate in prose what the code already says. If a
block needs a comment per step, the block needs better names or a smaller decomposition instead.
- When a change genuinely cannot be understood without a comment, do not write it silently. Explain what is unclear,
propose the exact comment text, and wait for the user to approve it before adding it.
### What a Comment Must Earn
- Write one when it carries something the code cannot: a non-obvious constraint, an upstream or platform behavior being
worked around, a reason the next reader would otherwise get wrong, a coupling to a value defined somewhere else.
- Never restate what the code already says. If the comment and the line below it convey the same thing, the comment is
noise.
- Never narrate. Not what this diff changed, not what the code used to be, not the order you did things in. Version
history belongs in git and change rationale belongs in the commit message.
- Never annotate line by line or statement by statement. A block that seems to need a comment per step needs better
names or a smaller decomposition instead — reach for structure before prose.
- Prefer encoding the intent in structure and naming. A named mixin, type, or method that makes the invariant hard to
break beats a paragraph asking the next reader not to break it.
- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file
that contains them. This does not need approval.
- These are not comments and must be preserved: analyzer and linter directives (`// ignore:`, `// ignore_for_file:`,
`// coverage:ignore`), license and copyright headers, code-generation markers, and comments inside vendored upstream
code such as `lib/widgets/open_container.dart`.
- These are not comments and are exempt from every rule here, including the density count: analyzer and linter
directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,
code-generation markers, and comments inside vendored upstream code such as `lib/widgets/open_container.dart`.
### Density
`tool/check_comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring
diffs under 20 added lines so small edits are never caught. The number is calibrated on this repository's own history:
healthy changes sit at or under 3.6%, while the core fix that prompted the gate ran 22.4%.
It is a ceiling on frequency, not a target to fill. Do not read `core/`'s inherited mihomo density as a quota either —
it is forked upstream code, not a house style.
Three gates run the same script, and they do not have equal force. The `PostToolUse` hooks in `.claude/settings.json`
and `.codex/config.toml` run after the tool and hand offending lines back as feedback; neither can undo the completed
write. The `comment-density` pre-commit hook fails the commit, and it is the only hard gate that covers every tool.
Behavior is pinned by `tool/check_comment_density_test.sh`, which CI runs directly.
When a change genuinely warrants more, raise the ceiling for that run rather than working around it:
`COMMENT_DENSITY_MAX=20 git commit`, or `SKIP=comment-density git commit` to step past it entirely.
### Where Knowledge Belongs
Pick the destination by where the constraint would be violated, not by how important it feels.
A comment is one of three destinations, and often the weakest. Pick by where the constraint would be violated, not by
how important it feels.
- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior
it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.
- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They
are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do
that.
- **A fact that is true only at one call site, and is not visible from that call site, stays a comment there.** Its
value is being in the reader's line of sight at the moment of the edit. `lib/common/constant.dart` is the model case:
the delay-test concurrency cap is bound to `mBatch` in `core/common.go`, and whoever changes that number must see the
constraint on the same screen.
- **A fact that is true only at one call site, and is not visible from that call site, is the one thing a comment does
better than a test or a document.** Its value is being in the reader's line of sight at the moment of the edit.
`lib/common/constant.dart` carries one: the delay-test concurrency cap is bound to `delayTestConcurrency` in
`core/common.go`, and whoever changes that number must see the constraint on the same screen. This is the case a
comment is for; the bar is that no test and no `.agents/` entry could hold the fact instead.
Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;
leaving a repo-wide policy as a comment reaches only the reader of that one file.
Before any of the three, prefer encoding the intent in structure and naming — a named mixin, type, or method that makes
the invariant hard to break beats prose that asks the next reader not to break it.
## Core API Safety
- `core/Clash.Meta` is a fork of mihomo, and changes to it are budgeted for features, not repairs. Fixing a bug there is
low priority even when the bug is real and the fix is small: every patch is one more thing to carry across an upstream
rebase. Solve it on the FlClash side of the boundary and note the mihomo behaviour you are working around. Reach into
the submodule only for a feature that has nowhere else to live, or when the problem is one the FlClash patches
themselves introduced — and say which of the two it is in the commit message.
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
a scope-specific cleanup API instead.
- The desktop IPC socket in `plugins/rust_api` admits a Unix peer only when its effective uid is the app's own or root:
the socket lives in `/tmp`, and the Core connects as root whenever it runs setuid for TUN or under the Linux Helper.
A launch mode that runs the Core as any other user has to widen `authorize_peer` in `ipc/platform.rs`, and Windows
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, JNI, and
desktop IPC. Do not double-encode `arguments`, `result`, or event batches.
- Keep high-volume log/request events separate from state-bearing events in `core/message.go`; bulk backpressure must not
evict delay, loaded-provider, or geo-update state.
- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,
route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest
entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push
out the request events the connection list is built from; state uses `enqueueState`, which never evicts, because a dropped `geoUpdate{updating:false}` leaves `isUpdatingProvider` stuck at true in the UI until
`UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full
queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports,
and reporting it from the message layer feeds the same batcher.
- `jni_get_string` in `android/core/src/main/cpp/jni_helper.cpp` `malloc`s and hands ownership to Go, which frees through
`free_string_func`. `quickSetup` relies on that: it reads its `*C.char` arguments inside a goroutine, after the JNI
wrapper has already returned. Switching the wrapper to `GetStringUTFChars`/`ReleaseStringUTFChars`, or freeing on the
C side, turns that read into a use-after-free.
- Go goroutines reach Java through `ATTACH_JNI()`, which attaches once with `AttachCurrentThreadAsDaemon` and detaches
from a `pthread_key` destructor at thread death. Do not restore a detach-per-call: `protect` runs once per outbound
socket and `onResult` once per event batch, and attach/detach takes ART's thread-list lock each time.
- Every JNI call into Kotlin must be followed by `jni_clear_exception`. A pending exception left in place aborts the
process on the next JNI call on that thread, so a throw in `protect`/`resolveUid`/`resolvePackage`/`onResult` becomes a
crash in unrelated code. For the same reason every one of those wrappers checks its `tun_interface`/`invoke_interface`
for `nullptr` first: ART aborts on a call through a null object, and a callback released by `TunHandler.clear` while a
connection is still resolving is exactly that.
- The Android bridge resolves an owner in two steps — `resolve_uid` then `resolve_package` — because mihomo fills
`metadata.Uid` from a procfs lookup that Android Q closed off. Collapsing them back into one call that returns only a
package name is what left every connection reporting uid 0, so `UID` rules matched nothing.
- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to
the log subscriber, batched, and handed back to `send`, so a send failure reported that way feeds itself; use
`logDeliveryError`, which writes to stderr and latches until a frame gets through or the next connection is installed.
A write that fails without putting a byte on the wire — host backpressure hitting `ipcWriteTimeout`, or a payload above
`maxIPCFrameSize` — drops that one frame and keeps the connection: the stream is still framed correctly, and tearing it
down here ends the read loop, and with it the Core process. A half-written frame whose write merely timed out is
resumed for as long as the stall lasts: Windows Modern Standby suspends the app while the Helper's Core keeps running,
so the host can stop draining for hours and still come back, and go-winio reports the expiry as its own `ErrTimeout`
rather than `os.ErrDeadlineExceeded`, which is why `send` checks `Timeout()`. The wait has no cap on purpose, and
`send` holds `writeMu` throughout, so every other frame — method responses and the single batcher goroutine behind
the event queues — waits behind the stalled one. Memory is bounded by the queues; what gives is delivery: the state
queue fills and `enqueueState` starts dropping, which is the case the `UpdatingAction` sweep above exists for. Nothing
on the Dart side restarts the Core over a stall: `CoreRpcClient` times each pending request out on its own and hands
the caller `null` (a `no_response` exception for message methods), and the sweep clears core-scope updating state
minutes later. Only a half-written frame that fails outright desynchronizes the stream, and that is the one case
`send` closes on.
- Core method handlers in `core/hub.go` are synchronous. Anything that must not block the dispatcher is spawned by
`safeGo`/`safeGoDetached` in `core/method.go`, which recover; a bare `go` in a handler puts a panic outside every
recovery and kills the process, which on Android is the whole application. The `//export` entry points in
`core/lib.go` do not reach `handleMethodCall`, so each one carries its own recovery.
- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in
`core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the
socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func
value. Stopping the TUN swaps `activeTunHandler` instead.
- `tunnel.AllProxies()` returns a shared, cached map — never modify it. The cache is invalidated by
`invalidateAllProxies` on `tunnel.UpdateProxies` and validated against each provider's `Version()`, so a rebuild
costs one read per provider rather than one per proxy. Anything else added to `tunnel/patch.go` that derives from the
proxy set needs both signals: the external controller can reload the config through `hub/route/configs.go` without
going through FlClash's `applyConfig`, so a hook on the FlClash side alone would miss a profile switch.
- Core state that mirrors mihomo state goes stale at the next `applyConfig`, which replaces every proxy, provider and
rule. Read the tunnel instead of caching a snapshot of it: `lookupExternalProvider` kept one that was rebuilt only
when the host asked for the provider list, and the host asks after a successful setup and not after a failed one, so
an update ran against a provider the tunnel no longer held — downloading, writing to disk and reporting success
against nothing.
- Selection writes take `selectMu`, not `configMu`. mihomo's `Selector.Set` has no lock of its own, so the writes need
mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is
what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under
`configMu`, fixing the order as `configMu` → `selectMu`.
- Queueing for the delay-test semaphore and probing the node each get the full timeout in `handleTestDelay`. With one
shared deadline, a node that waited behind a saturated semaphore reached `URLTest` with almost nothing left, so a bulk
test of a large subscription reported whatever sat at the back of the queue as unreachable.
- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy:
`handleTestDelay` returns within its two timeouts on every path. `asyncTestDelay` therefore returns null instead of a
`-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a
timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's.
- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0
used to mean "testing", which let a result and the state of a test overwrite each other and left cards spinning
forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a
reply arriving; core status leaving `connected` cancels every run in flight.
- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background
scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the
batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData`
claims per resource and `updater.GeoUpdateHook` releases.
- A failed `applyConfig` rolls the tunnel back to the default config, and that rollback is the whole recovery:
`handleSetupConfig` returns the error and stops there. Do not add a teardown on top of it — stopping the listeners
takes the app offline over a profile the user can still switch away from, and the error already reaches the host,
which is what surfaces the failure (`MessageException` on the Flutter side, the config-error toast on Android). Keep
an empty `config.yaml` out of the failure path — it is how the app says "no profile selected", and `loadConfig`
resolves it to the defaults.
- Package `init` in the Android library runs while the `.so` is being loaded, so a panic there takes the application
down before it can report anything. `platform/limit.go` arms an fd-pressure probe and degrades to never blocking when
it cannot; keep that shape for anything else `init` sets up that correctness does not depend on.
- Every `android && cgo` file in `core/` is compiled only by the NDK-backed CI step in the `go` job. Keep the build
constraints as `android && cgo` / `!(android && cgo)`: a bare `cgo` constraint makes `go build ./...` fail in `core/`
on any developer machine, because the files it pulls in need the NDK.
## Lifecycle Rules
- Crash recovery is owned by `BootGuard` (`lib/common/boot_guard.dart`), and the signal it acts on is the persisted
`BootRecord`, never a crash reporter. The record is stamped `starting` before `startCore` and `running` once
`_initApp` finishes, so only a launch that died before reaching `running` is a failed launch — a crash after hours of
runtime, or a process the system reclaimed in the background, is not one. `ApplicationExitInfo` can only veto a
failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through
`handledExitAt` so the same exit cannot be counted twice.
- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and
`markClosed` return without touching preferences. The desktop has neither of the two attribution sources — no
`ApplicationExitInfo` equivalent and no Crashlytics toggle — so a bare sentinel there would read a window closed on
the disclaimer dialog, or a session ended by shutdown, as a failed launch. Keep the platform check inside the guard;
callers in `bootstrap.dart` and `SystemAction` stay unconditional.
- `FirebaseCrashlytics.didCrashOnPreviousExecution()` corroborates a failure and must not trigger one. Its marker file is
cleared by a background initialization that `dataCollectionArbiter` blocks while collection is disabled, which is the
default here, so one real crash makes it return true on every later launch. That latch is what made the app clear the
selected profile on every cold start; it is also why the probe is only read when `crashlytics` is enabled, keeping
Firebase uninitialized until the user consents.
- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from
`crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config.
- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets,
managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`.
- `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must
@@ -82,21 +262,166 @@ the invariant hard to break beats prose that asks the next reader not to break i
actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.
- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the
broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.
- Anything that probes through the outbound route is a `RoutedProbe` judged against `routeTrackerProvider` (Route
Consistency in `.agents/architecture.md`); the Core stamps every answer and publishes its picks, so do not add a
change signal, a trigger counter, or a `coreStatusProvider` watch of your own.
- Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or
overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately.
- Safe mode (`SAFE_MODE` define, see the safe mode section of `.agents/architecture.md`) is consulted by each host
integration at its owner, never by rewriting the user's settings. `safeModeBuild` is for handles created before the
container exists; everything else reads `safeModeProvider` so a test can override it.
- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to "`show` was never called".
`AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins.
Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)`
means `WM_DESTROY` never runs.
- The `tray` plugin owns call ordering, idempotency, serialization, and unchanged-payload suppression. Application code
declares desired state through one `Tray.show(TraySpec)` call and must not add platform branches to work around
ordering. Platform branches in `lib/common/tray.dart` are only for deliberate product differences (macOS speed title
and group submenus); query `Tray.instance.capabilities` for ability differences.
- Every native `show` returns whether the tray now reflects the payload, and reports `false` instead of showing a broken
icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather
than suppressed until restart. Any test that mocks the `tray` channel must return `true` from `show`.
- Reading the Wi-Fi SSID is opt-in work, not ambient state. `ConnectivityManager` reads it only while `excludeSSIDs` is
non-empty, because that list is its only consumer through `suspendProvider`, and the read costs a blocking platform
call plus a location permission on Android and macOS. A second consumer must widen that gate, not drop it.
- On Android the `wifi_ssid` permission is only `granted` once `ACCESS_BACKGROUND_LOCATION` is held too (Q+). Fine
location alone reads the SSID in the foreground but returns `UNKNOWN_SSID` once the app is backgrounded, which is
where on-demand spends most of its time. `getSsid` itself only needs fine location, so the split stays inside the
plugin.
- No `wifi_ssid` implementation may answer `getSsid` on the platform thread. Android resolves through a network callback
with a timeout, Linux through `g_task_run_in_thread`, and macOS through `ssidQueue` after checking the location
authorization, because CoreWLAN reaches `wifid` over XPC and a wedged daemon would freeze the window. Windows is the
outstanding exception: `WlanQueryInterface` still runs inline, since replying off-thread there needs a window-message
hop that the plugin API does not provide.
- The delayed DNS re-check `NetworkObserveModule.onLosing` posts is deliberately left un-deduplicated. The runnable
re-reads `networkInfos` and does nothing when `onLost` already dropped the network, `updateDns` returns early when the
resolved list is unchanged, and `stop()` clears the handler queue, so a network that reports `onLosing` repeatedly
costs one comparison per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds
state that has to stay in sync with the map.
## Testing Rules
The `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage
collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper;
verify cross-language protocol behavior through shared Dart contract tests under `test/core/` and native platform build
checks.
collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper,
plus an NDK-backed `GOOS=android` vet that covers the `android && cgo` files the first two exclude; verify cross-language
protocol behavior through shared Dart contract tests under `test/core/` and native platform build checks.
Use `CoreController.test(mock)` to inject a mocked `CoreHandlerInterface`. Call `CoreController.resetInstance()` in `tearDown` to clean up the singleton between tests.
A Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` —
leaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever
connection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with
`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test
connection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches
it in an unrelated test.
Register fallback values for freezed params used with `any()` matchers.
Use `ProviderContainer` directly for simple Riverpod provider tests. The generated Riverpod `update()` method takes a callback:
`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a
group's floor when new tests lift it; do not lower one to make a run pass.
Every measured group needs a floor. A group the report measures but `_groupFloors` does not declare fails the run, so
adding a top-level directory under `lib/` means adding its floor in the same change. Set a new floor at or just below
the coverage the directory actually has; the point is to stop a slide, not to backfill tests before the directory can
land.
Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and
touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read
resolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim
the singleton calls `CoreController.resetInstance()` in `tearDown`.
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
path. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`.
Three globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it:
`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every
`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry
key), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early
under `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test
at all; `test/common/launch_test.dart` pins the early return instead.
`pumpAndSettle` never returns on a page holding `EditorView` (what `EditorPage` embeds): the code editor blinks its
caret forever, so frames keep being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in
`common/task.dart` hand work to a real isolate through `compute`, which only runs outside the fake-async zone, so a test
awaiting one needs `tester.runAsync(...)` between the pumps.
The `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it
replaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the
setter is not, so closing there would either be unawaited or force the seam to become async for no gain. A test that
does open a file-backed database owns closing it.
`system.isAndroid` / `isMacOS` / `isWindows` / `isLinux` read `dart:io` `Platform` and cannot be overridden, unlike
`debugDefaultTargetPlatformOverride`. A branch behind one of them is only ever exercised on a host that matches it, so CI
(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior,
and leave headroom under a group floor that covers such a branch.
A platform decision that drives layout takes `isDesktop`/`isMacOS` as parameters and reads `system` only at the call site,
so every platform's outcome is reachable from one host. `getWindowHeaderHeight` and `showsWindowHeader` in
`lib/common/layout.dart` own the window header rule for both `WindowHeaderContainer` and `overlayTopOffset` — they must
agree, or the content is offset by a header that is not there. `WindowHeaderBar` takes its height and slots as arguments,
which is what lets `test/manager/window_header_test.dart` measure the Windows caption bar on a macOS host.
A `Stack` under loose constraints sizes to its non-positioned children, and falls back to `constraints.biggest` only when
it has none — so a bar that fills the window must keep every slot positioned. One non-positioned child is enough to
collapse `WindowHeaderBar` to that child's width: the macOS title did exactly that, leaving the app name over the traffic
lights and the raw window painted black beside it, while Windows, whose slots are all positioned, stayed correct.
`WindowHeaderLayout` positions the header across the top for the same reason, and the macOS group asserts the bar's
width, not just its height — a height-only assertion cannot see this.
The same seam carries the two other places a platform decision changed what was built: `AppTray` holds `isMacOS`/
`isWindows` as state — `AppTray()` fills them from `system`, `AppTray.forPlatform` is the test seam — and `OnDemandView`
takes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because
`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS
shape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every
non-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window` channel mock that
answers `isMaximized`/`isAlwaysOnTop` with a bool.
Auto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,
`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching
rebuilds the provider from its override and silently discards whatever the code under test wrote. Add
`container.listen(theProvider, (_, _) {})` in the harness, as `overwrite_stage_flow_test.dart` does. The staging flow also
re-arms its debounce when it clears the stage, so drain it (`pump` past the duration, then unmount) or the binding fails
the test on a pending timer.
A field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`,
`PageController`, `AnimationController` or `StreamController` must be released in the same file.
`test/lint/disposable_field_test.dart` enforces this by scanning `lib/`, because no lint covers it: `close_sinks` only sees
sinks, and nothing in the standard set tracks `ChangeNotifier` disposal. A field that genuinely outlives its owner goes in
that test's `_allowed` set with the reason, not left bare. Controllers received as widget parameters belong to the caller
and are out of scope.
An `IconButton` whose icon is an icon needs a `tooltip`. It is the button's only accessible name — without it TalkBack and
VoiceOver announce nothing and the desktop build shows no hover hint. `test/lint/icon_button_tooltip_test.dart` enforces
it and skips exactly two shapes: an `icon:` holding a `Text`, which is already a visible label, and
`views/dashboard/widgets/core_status_button.dart`, which takes its label from an enclosing `Tooltip` (a second test fails
if that wrapper disappears). Reuse an existing string before adding one; a label that depends on state goes on the button
inside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden
behind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern.
A tooltip needs an `Overlay` ancestor at build time, not at hover time: `RawTooltip` builds an `OverlayPortal`, so a
button whose `tooltip` has nowhere to go throws "No Overlay widget found" and takes its whole subtree down with it.
Everything `buildManagerStack` wraps around `MaterialApp.builder`'s child sits *above* the app Navigator and therefore
above the only Overlay in the tree. A manager that renders a tooltip — `WindowHeaderLayout` and its caption buttons are
the case that broke Windows while macOS, which renders a bare title there, stayed clean — hosts its own with
`Overlay.wrap`, spanning the window so the tooltip is not clipped to the widget that owns it. A test only reproduces this
by mirroring that topology: build the widget from `MaterialApp.builder`, never from `home:`.
A public top-level declaration that nothing outside its own file references is dead, and no lint catches it:
`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every
"unused import" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the
`final appPath = AppPath()` singletons next to them — appear nowhere else, counting generated code as a consumer (a
riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or
typedefs are skipped: those are reached through the types they attach to, never by name.
A `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then
immediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown
throws "`…State.dispose failed to call super.dispose.`" in debug and profile builds. Declare the override as `void
dispose()` and hand async teardown to `unawaited(...)`; `Future<void> dispose() async` compiles and is the shape that
invites the bug.
Use `ProviderContainer` directly for simple Riverpod provider tests, released with `addTearDown(container.dispose)`.
Assert against the provider's generated public API, not its implementation, re-read source defaults before asserting
them, and wait on provider futures or state changes rather than fixed sleeps. The generated Riverpod `update()` method
takes a callback:
```dart
notifier.update((state) => newValue);
@@ -107,6 +432,77 @@ When testing freezed models with nested objects, always round-trip through `json
For async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success,
failure, disposal, and any timer boundary that changes visible state.
## Running the App Locally
- An agent starts FlClash on the host only as a safe mode build (`--dart-define=SAFE_MODE=true`, see
`.agents/commands.md`). A normal build takes over the host network, so the user runs it, never the agent.
- Never end a FlClash process the agent did not start, and never kill by name (`pkill`, `killall`, a pattern on the
executable path): the user's own instance matches too. Record the pid of every process you launch, check with
`ps -o pid,command -p <pid>` that its executable is your own build output, and end only that pid.
- Only a macOS or Android debug build has its own data directory; every other build shares the installed app's data
directory and single-instance lock, so it cannot start while the user's instance runs. `.agents/commands.md` lists
which builds are kept apart.
## Commit Messages
Subjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs
`tool/check_commit_msg.sh`:
```text
<type>[(scope)][!]: <description>
```
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.
- Scope is optional and lower case; use a comma to list several, as in `fix(core,android)`.
- `!` before the colon marks a breaking change.
- Descriptions start in lower case, omit the trailing period, and keep the whole subject within 100 characters.
Identifiers and acronyms keep their own casing, as in `fix(ui): AppBar text is truncated`.
- `Merge`/`Revert` subjects and `fixup!`/`squash!` commits are exempt.
- No `Co-authored-by` trailer crediting a coding agent, whatever that tool's own convention says. The history
records who owns the change, not which tool typed it; human co-authors are still fine. The hook rejects the
known agent identities.
Write what the change does, not that something changed: `perf(views): stop redoing per-frame work in build`, not
`Optimize more details`.
Install the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`.
### Fixup Commits
A follow-up to a change that has not shipped in a stable release goes in as `git commit --fixup=<target>`, aimed at the
commit that introduced what it amends.
- Only stable tags (`vX.Y.Z`) close a commit. A target reached only by pre-release tags (`vX.Y.Z-pre.N`) is still open
for fixups, so do not steer away from it or flag it.
- A commit a stable tag contains takes no fixup; land the follow-up as a regular commit. Check with
`git tag --contains <target> | grep -v -- '-pre'`, which must print nothing.
- Asking for a fixup means creating the `fixup!` commit only. Do not run `git rebase --autosquash` or otherwise fold it
into its target unless the user asks for the squash.
### Changelog Trailers
`tool/changelog.dart` builds the user facing changelog from the commit history, so the trailers below are the copy that
ships to users. The subject stays the developer facing summary and is only the fallback.
```text
feat(profiles): support per-profile override script
Changelog: Per-profile override scripts
```
- `Changelog:` is the English entry. `Changelog: skip` drops the commit from the changelog entirely.
- The changelog is English only. A `Changelog-<locale>:` or `Breaking-<locale>:` trailer fails the hook, because
translated release copy does not belong in the commit history. Translate after the fact if ever needed, not in the
commit message.
- `Changelog-Type:` moves an entry into another group, for example to promote a `refactor` that users will notice. Valid
values are `breaking`, `feat`, `fix`, `perf`, `revert`.
- `BREAKING CHANGE:` is required whenever the subject carries `!`, and its text becomes the breaking entry. A `!` commit
therefore needs two lines of copy: the footer for the breaking entry and `Changelog:` for the normal one.
`feat`, `fix`, `perf`, `revert` and breaking commits are collected by default; every other type is dropped unless it
carries a `Changelog:` trailer. Commits missing a trailer reuse their subject; for `feat`, `fix` and `perf` the hook
says so without blocking.
## Generated Code
Do not manually edit generated files under:
@@ -118,3 +514,23 @@ Do not manually edit generated files under:
- `lib/l10n/intl/`
After schema, model, or provider changes, run build generation and include focused tests when behavior changes.
`lib/l10n/l10n.dart` is the one file that still imports `package:flutter/material.dart`.
`intl_utils` hardcodes that import in its own template, so regenerating rewrites it and
there is nothing to fix here; `test/lint/design_package_test.dart` exempts the generated
l10n paths for that reason. It is harmless because the file only needs `Locale`,
`BuildContext`, `Localizations` and `LocalizationsDelegate`, which the legacy library and
`material_ui` both re-export from the same `package:flutter/widgets.dart`. Everything a
human writes takes Material from `material_ui`; `cupertino_ui` is banned outright and
survives only as a transitive dependency of `material_ui`.
Strings live in `arb/intl_{en,zh_CN,ja,ru}.arb` — flat JSON, no `@` metadata. Add a key to all four, then regenerate with
`dart run intl_utils:generate`, which rewrites `lib/l10n/`. A key present in only some locales silently falls back to
English at runtime, so add the translation rather than leaving it out.
Some labels are not reached through the generated `AppLocalizations` getters at all. `Intl.message(<runtime string>)`
builds the key from an enum name or a stored string — `action_${HotAction.name}`, `${DynamicSchemeVariant.name}Scheme`,
`NavigationItem.description`. The analyzer sees nothing, and a failed lookup returns the key itself, so a stale key ships
as `routeMode_config` in the UI rather than throwing. `test/lint/dynamic_message_key_test.dart` expands those families
from the real enums and fails when a derived key is missing from any locale; every `Intl.message` site in `lib` must be
registered there, so a new dynamic key cannot be added without also declaring what builds it.
+9 -3
View File
@@ -1,18 +1,24 @@
# Agent Skills
Repo-scoped Codex skills live under `.agents/skills/*/SKILL.md`. Codex can discover these by skill `name` and `description`, then load the full instructions only when a task matches.
Repo skills live under `.agents/skills/*/SKILL.md`, where Codex discovers them directly. Claude Code discovers them
through the symlinks in `.claude/skills/`, one per skill, pointing back at the `.agents/skills/` directory. Both tools
match a task against the skill `name` and `description`, then load the full instructions only when it matches.
## Available Repo Skills
- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.
- `provider-tests`: Riverpod provider, notifier, and state-management tests.
- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, async feedback, and user-facing interactions.
- `core-platform`: Core lifecycle/process ownership, Android services, Go event delivery, desktop IPC, platform managers,
VPN/TUN, and Windows Helper flow.
- `pre-commit-quality-review`: lightweight triage before commits, with scoped structural review and user decisions on concrete regressions.
## Authoring Notes
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`.
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`, and add the matching
`.claude/skills/<skill-name>` symlink so Claude Code sees it too; remove both when retiring a skill.
- Git stores the symlinks as links only where the checkout supports them. A Windows checkout needs
`core.symlinks=true` and Developer Mode, otherwise the links become plain files and Claude Code loads no repo
skill there; Codex is unaffected because it reads `.agents/skills/` directly.
- Keep skill descriptions trigger-focused and start them with `Use when...`.
- Keep long reference material in `.agents/*.md`; skills should link to it instead of duplicating it.
- Put command permission rules in `.codex/rules/*.rules`, not in this file.
+48 -58
View File
@@ -1,73 +1,63 @@
---
name: core-platform
description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, VPN/TUN, or Windows Helper flow.
description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, build hooks, system proxy, tray, VPN/TUN, or the Windows or Linux Helper.
---
# Core And Platform
## When To Use
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules, build
hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules,
build hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
## Workflow
## Read First
1. Identify the authoritative owner before changing behavior:
- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.
- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.
- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.
- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.
- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.
- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows Helper HTTP contract:
`lib/core/desktop/helper_client.dart` and `services/helper/`.
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; UI/event observation: `lib/manager/`.
2. Trace every entry path into that owner, including UI/provider calls, Quick Settings, notification actions, Always-on VPN,
revoke callbacks, application exit, and crash/disconnect recovery. Lifecycle callbacks are not implicit user intent.
3. Preserve latest-intent semantics:
- Desktop revisions converge to running/restarted/stopped/closed and report applied/coalesced/superseded outcomes.
- Android Flutter calls stay optimistic; `ServiceState` identity-checks the latest native `RunRequest`.
4. Route feature calls through `CoreController` and `CoreHandlerInterface`. Do not bypass desktop process leases or create a
second Android service binding owner.
5. Keep JSON envelopes and event shapes identical across Dart, Go, JNI, and desktop IPC. If event traffic changes, preserve
the separate priority and bulk queues in `core/message.go`.
6. Keep shutdown single-owned and terminal. `SystemExitCoordinator` sequences resource cleanup, window close, Core close,
and process exit; widget/manager disposal must not race it.
7. Add or update focused tests at the narrowest layer, then run the matching commands from `.agents/commands.md`:
- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.
- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.
- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.
- Android Kotlin: compile each touched Gradle module with JDK 17.
- Windows Helper: Cargo format/tests; run the `windows-service` feature on Windows.
8. Explicitly state host gaps. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, and Windows Service
Control Manager behavior need their real platform even when portable tests pass.
- `.agents/rules.md`: Core API Safety and Lifecycle Rules are binding. They carry the mihomo-fork patch budget, the
JSON envelope and `core/message.go` queue rules, JNI ownership, `BootGuard`, tray, and SSID rules, and every
lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,
optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).
- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,
Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,
the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.
## Reference Files
## Owners
Read `.agents/architecture.md` for the current core modes, manager stack, build hooks, local plugins, and Windows helper notes.
Every behavior here has one authoritative owner, and a change lands there:
- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.
- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.
- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.
- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.
- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.
- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows and Linux Helper HTTP contract:
`lib/core/desktop/helper_client.dart` and `services/helper/`.
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; application exit:
`SystemExitCoordinator`; UI/event observation: `lib/manager/`.
An owner has more entry paths than the UI: provider calls, Quick Settings, notification actions, Always-on VPN, revoke
callbacks, application exit, and crash/disconnect recovery all reach it. Always-on startup is explicit through
`VPN_START_REQUESTED` and revoke through `VPN_REVOKED`; service creation or destruction is never read as intent.
## Verification
Focused tests live at the narrowest layer, and the matching commands are in `.agents/commands.md`:
- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.
- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.
- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.
- Android Kotlin: compile each touched Gradle module with JDK 17.
- Helper: `cargo fmt --check` and `cargo test`; the `windows-service` feature only builds on Windows.
State host gaps explicitly. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, polkit and systemd
on Linux, and Windows Service Control Manager behavior need their real platform even when portable tests pass.
## Pitfalls
- Keep the Windows Helper protocol and Core SHA256 validation identical across
Flutter build modes; the Helper owns executable integrity checks.
- Protocol version 6 uses a 32-character lowercase-hex session ID. `/start` must return the submitted session and PID;
`/stop` must never terminate a different session; Dart must verify the connected named-pipe peer PID.
- `/start` must release the previously managed Core before it verifies, so no `/start` outcome leaves a Helper-managed
Core behind for the caller's direct-launch fallback to race.
- The Helper owns a managed Core until its exit is confirmed. A `200` from `/stop` means the Core is gone; when
termination cannot be confirmed the Helper keeps the child and answers `coreStopFailed`, and `/start` reports the same
code instead of spawning a replacement. Keep that code out of the Dart pre-spawn fallback set in
`helper_client.dart`, or the direct launch will race a Core the Helper still owns.
- TUN is not a required run condition. Degrading to the unelevated direct Core — and silently losing TUN — is the
expected outcome whenever the Helper path fails; do not fail the launch instead.
- A desktop process lease with unconfirmed exit must remain owned until cleanup succeeds. Do not discard it and start a
replacement Core.
- `CoreController.close()` is terminal. Do not call it from a reusable manager lifecycle or recover by starting it again.
- `ServiceBroadcastReceiver.goAsync()` must finish once even on timeout; its watchdog releases the broadcast only and must
not become a service timeout.
- Do not interpret service creation/destruction as start/stop intent. Always-on startup is explicit through
`VPN_START_REQUESTED`; revoke is explicit through `VPN_REVOKED`.
- Keep log/request floods from evicting state-bearing Core events. Each queue may evict only its own oldest item.
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
a scope-specific cleanup API instead.
- `plugins/setup/` is a build harness, not a Dart API plugin.
- Build hooks can trigger Go or Rust compilation indirectly through Flutter platform builds.
- A `200` from Helper `/stop` means the Core is gone. When termination cannot be confirmed the Helper keeps the child
and answers `coreStopFailed`, and `/start` reports the same code instead of spawning a replacement. Keep that code
out of the Dart pre-spawn fallback set in `helper_client.dart`, or the direct launch races a Core the Helper still
owns.
- The Helper owns executable integrity: Core SHA256 validation and the protocol stay identical across Flutter build
modes, so a debug build is not exempt.
- `plugins/setup/` is a build harness, not a Dart API plugin, and its hooks can trigger Go or Rust compilation
indirectly through Flutter platform builds; a missing toolchain fails `flutter test`, not only `flutter build`.
+12 -12
View File
@@ -7,39 +7,39 @@ description: Use when changing FlClash UI text, scanning for hardcoded Chinese s
## When To Use
Use this for localization work inside `/Users/Shared/follow/FlClash`, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values.
Use this for localization work in this repository, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values.
Do not use this for README translation sync or manual edits to generated localization Dart.
## Workflow
1. Confirm `pubspec.yaml` still uses `flutter_intl`, source ARBs under `arb/`, and generated output under `lib/l10n/`.
2. Scan user-facing Dart text before opening many files:
1. Scan user-facing Dart text before opening many files. `flutter_intl` reads `arb/` and writes `lib/l10n/`:
```bash
rg -n "[\\p{Han}]" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'
```
3. Inspect the smallest relevant call sites and nearby ARB keys.
4. Add or update every source ARB:
2. Inspect the smallest relevant call sites and nearby ARB keys.
3. Add or update every source ARB:
- `arb/intl_en.arb`
- `arb/intl_zh_CN.arb`
- `arb/intl_ja.arb`
- `arb/intl_ru.arb`
5. Replace inline strings with existing project accessors:
- Widgets with `BuildContext`: `context.appLocalizations.key` from `common.dart`.
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `app_localizations.dart`.
6. Regenerate:
4. Replace inline strings with existing project accessors:
- Widgets with `BuildContext`: `context.appLocalizations.key` from `lib/common/context.dart`.
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `lib/common/app_localizations.dart`.
5. Regenerate:
```bash
dart run intl_utils:generate
```
7. Verify changed Dart files with `flutter analyze` when practical.
8. Re-run the Han-text scan for targeted strings.
6. Run `flutter analyze --no-fatal-infos` and `flutter test test/l10n test/lint/dynamic_message_key_test.dart`. The
lint test covers the labels built from a runtime key, such as `action_${HotAction.name}`, which the analyzer cannot
see; the ARB section of `.agents/rules.md` explains them.
7. Re-run the Han-text scan for targeted strings.
## Pitfalls
- If ja/ru still show English, fix ja/ru source ARB values and regenerate. Do not edit generated Dart.
- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.
- If generator or analyzer hits local cache permission friction, rerun serially before treating it as a code issue.
@@ -0,0 +1,105 @@
---
name: pre-commit-quality-review
description: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.
---
# Pre-commit Quality Review
Check that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,
so later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill
grants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is
not consent.
## Triage
Most commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a
repository-wide scan.
- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the
normal commit flow without extra confirmation. Say the review was skipped, never that it passed.
- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions
change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws
responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.
- An explicit structural review request bypasses triage.
The snapshot under review is the intended commit compared with its baseline:
| Operation | Baseline |
|---|---|
| Regular commit | `HEAD` |
| Amend | Parent of the commit being replaced |
| Squash | Version preceding the agreed range |
| Initial commit | Empty tree |
Ask when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of
partially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial
staging.
## Review the Final Structure
Scope the review to the responsibility boundaries this change touched. Read the complete final implementation of the
affected units and compare before and after, not only the last patch. Report issues this change introduced or
worsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a
concrete concern needs, and disclose missing essential context rather than claiming a complete pass.
Focus on:
- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled
with implementation detail; responsibilities in layers that should not own them.
- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate
nothing; similar names or interfaces hiding different responsibilities.
- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually
dependent helpers; names and decomposition that do not reduce reading effort.
- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,
scattered ownership of error handling, cleanup, or state updates.
- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite
invalidated; coupling that makes existing test boundaries hard to use.
Not findings: length, a missing design pattern, naming taste, or anything without code evidence and a real
maintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,
or demand abstractions for speculative needs.
Business logic, UI behavior, performance, security, platform compatibility, and full functional review are out of
scope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or
widening this review.
## Findings and the User's Decision
A finding qualifies only when all three hold:
- Before-and-after code and the necessary call relationships show this change introduced or worsened it.
- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one
responsibility. Clarity or elegance alone is not enough.
- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.
With no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the
existing commit authorization. Do not ask for confirmation because a review happened, and do not collect optional
improvements into a suggestion list.
With qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,
and give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the
smallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to
optimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the
user recognizes their configured workflow, for example: "The structural issues above support consolidating the
duplicate state-update paths. Should I make that change before committing?"
Existing explicit authorization for these findings is enough to act without asking again. The user may accept all,
some, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were
restaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.
## Resolve and Finish
Once authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task
scope; explain separately if either must change. Verify only the accepted findings and the direct effects of the
adjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold
and exceeds the existing authorization.
Before committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and
review only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,
`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse
results that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot
as verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the
commit.
Report the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.
Claim the commit is done only after it exists.
-37
View File
@@ -1,37 +0,0 @@
---
name: provider-tests
description: Use when adding or updating FlClash Riverpod provider tests, notifier tests, or state-management tests in this repository.
---
# Provider Tests
## When To Use
Use this for tests under `test/providers/` or any change that validates Riverpod providers, generated notifiers, app state defaults, or provider interactions.
For broader test expansion, pair this with `.agents/rules.md` and `.agents/commands.md`.
## Workflow
1. Read the provider under test and its generated public API before writing assertions.
2. Use `ProviderContainer` directly when no widget tree is needed.
3. Dispose containers in teardown or with `addTearDown(container.dispose)`.
4. Prefer generated notifier APIs over implementation details. Generated `update()` takes a callback:
```dart
notifier.update((state) => newValue);
```
5. Mock external dependencies with `mocktail`; register fallback values for freezed params used with `any()`.
6. Keep tests focused on behavior: defaults, state transitions, persistence boundaries, and side effects.
7. Run the narrowest relevant test first:
```bash
flutter test test/providers/
```
## Pitfalls
- Do not use `dart test`; FlClash models and provider tests may depend on Flutter types.
- Re-check source defaults before asserting them; provider defaults can drift.
- If async provider timing matters, wait on provider futures or state changes instead of fixed sleeps.
+92 -31
View File
@@ -1,47 +1,108 @@
---
name: ui-work
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, navigation surfaces, async feedback, or user-facing interactions.
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, icons or glyphs, shapes and corner radii, navigation surfaces, async feedback, or user-facing interactions.
---
# UI Work
## When To Use
Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows, dialogs, and interaction behavior.
Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows,
dialogs, and interaction behavior.
## Workflow
## Conventions
1. Locate existing nearby widgets and reuse their patterns before adding new abstractions.
2. Follow current Material You and Surfboard-like visual conventions.
3. Use existing providers, notifiers, and helpers where possible.
4. Keep `child:` last in widget constructors.
5. Prefer `const` constructors and final locals.
6. Localize user-facing text through ARB; use `localization` when text changes are non-trivial.
7. Add focused widget tests when behavior changes, especially for rendering states, taps, scrolling, and empty/error states.
8. For asynchronous controls, define separately:
- authoritative provider/domain state;
- display-only state such as a minimum progress duration;
- tap policy while work or display holds are active;
- failure/disposal cleanup, normally in `finally` for animations and timers.
9. Run targeted verification:
- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the
Material You, Surfboard-like look the rest of the app has. Do not introduce a visual system for one screen or rewrite
a layout the request does not need.
- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,
and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not
await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).
- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows
section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.
- Every icon lives in `lib/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it
with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled
button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the
fill animation, and when to fill.
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
keep scrims from stacking.
- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)
rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in
`.agents/architecture.md` has the reasons.
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
- User-facing text goes through ARB; use `localization` when text changes are non-trivial.
- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a
minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in
`finally` for animations and timers. `CoreStatusButton` and the proxies delay test action are the reference implementations,
described under User-Facing Core And Delay Feedback in `.agents/architecture.md`.
```bash
flutter analyze
flutter test test/widgets/
```
## Corner Radii
All corner radii come from `lib/common/shape.dart`: a token, a derived value, or a named off-scale value passed to its
builders. Never write a bare radius literal at a call site.
The scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 logical pixels tall
reads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.
| Token | Value | Shortest side | Use |
| --- | --- | --- | --- |
| `none` | 0 | - | square edges, and the flat side of a grouped run |
| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |
| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |
| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups, FAB |
| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |
| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |
| `xxl` | 28 | over 200 | sheets, dialogs, full-screen containers |
| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |
- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`
builders; `AppShape` mirrors it as `RoundedSuperellipseBorder`, `full` included, plus `circle`, `input`, and the
`all`/`top`/`vertical`/`of` builders. Use `AppShape.full` for pills, never `StadiumBorder`; the Corner Radius
section of `.agents/rules.md` explains why and lists the superellipse API per layer.
- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever
the size comes from a `LayoutBuilder` or scales with text.
- `ThemeData.withAppShapes` in `lib/common/shape.dart` already applies the scale to card, dialog, bottom sheet, snack
bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon
button themes. Do not restate those shapes at call sites, and
leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its
corner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`
(`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy
group header in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the
one-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card at `ProxyCardType.min` is only about
64 tall, which caps its radius at 21, so `xl` would break on a setting the user can change at any time while `lg`
clears all three heights and the smallest dashboard tile.
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts
the floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8
up. `AppInputBorder` clips the notch region and paints the full superellipse through it, as Flutter's own
`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.
Nested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute
the outer value, as `_cardRadius` in `lib/widgets/popup.dart`, `thumbRadius + _kThumbInset` in `lib/widgets/tab.dart`, and the
selection ring in `lib/widgets/palette.dart` do. Adding a token for one of these is how a scale grows without bound.
A component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/widgets/sidebar.dart` does with 10, between the
chip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.
## Verification
- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,
including the exception path of any async action. Run `flutter analyze` and `flutter test test/widgets/` plus the
tests of the touched view.
- The scans in `test/lint/` fail on the common UI mistakes and run as part of `flutter test`: an `IconButton` whose
icon is an icon needs a `tooltip`; a field that constructs its own controller, notifier, or focus node is released in
the same file; a public declaration nothing references is dead. `.agents/rules.md` explains each rule and its
allowlist.
## Pitfalls
- Do not introduce a new visual system for one screen.
- Do not manually edit generated localization or provider files.
- Avoid broad layout rewrites unless the requested change requires them.
- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors
bypass them immediately.
- Do not leave loading animations active when callbacks throw. Test the exception path, not only the successful tap.
## Current Interaction Examples
- `CoreStatusButton` watches `coreStatusProvider` but keeps its 600-millisecond connecting hold locally. Taps are ignored
during the hold or genuine connecting state; disconnected cancels the hold immediately.
- Proxy delay testing writes `0` while pending, the measured delay on success, and `-1` on failure. `DelayTestButton` resets
its animation in `finally`.
- Do not leave loading animations active when callbacks throw.
- Do not manually edit generated localization or provider files.
+5
View File
@@ -0,0 +1,5 @@
# Worktrees
Claude Code, Codex, and Gemini each create git worktrees. `tool/worktrees.sh list` shows every worktree with its
owning tool and whether it holds uncommitted work; `tool/worktrees.sh prune` removes the clean ones and keeps
dirty ones unless `--force` is passed. `.claude/worktrees/` is ignored by git; `.claude/settings.json` is tracked.
-14
View File
@@ -1,14 +0,0 @@
---
name: implementer
description: Use for scoped feature work, bug fixes, refactors, and focused test updates after the requirements are clear.
tools: Read, Grep, Glob, Bash, Edit, Write
model: sonnet
effort: high
---
Implement the requested change in the smallest appropriate scope. Follow the repository instructions in AGENTS.md and
the
linked .agents documentation, preserve existing lifecycle and serialization contracts, and keep unrelated working-tree
changes untouched. Trace the affected call path before editing, update focused tests when behavior changes, and report
the
files changed plus the verification actually run.
-11
View File
@@ -1,11 +0,0 @@
---
name: researcher
description: Use for fast read-only codebase exploration, call-path tracing, and locating relevant tests before implementation.
tools: Read, Grep, Glob
model: sonnet
effort: medium
---
Trace the actual call path instead of stopping at the first matching file. Identify the relevant symbols, lifecycle owner,
cross-platform boundary, existing tests, and focused verification commands. Do not modify files. Return concise,
evidence-based findings with file paths and line references when available.
-12
View File
@@ -1,12 +0,0 @@
---
name: reviewer
description: Use after code changes for correctness, regression, lifecycle, contract, and test review.
tools: Read, Grep, Glob, Bash
model: opus
effort: high
---
Review the current changes without editing files. Inspect the diff, trace the relevant call paths, and check platform
ownership, serialization or IPC contracts, error handling, and test coverage. Run focused read-only checks when useful.
Report actionable findings first, ordered by severity, with file paths and line references; state explicitly when no issue is
found and note any validation or platform coverage gaps.
+17
View File
@@ -0,0 +1,17 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PROJECT_DIR:-.}/tool/check_comment_density.sh\"",
"timeout": 15,
"statusMessage": "Checking comment density"
}
]
}
]
}
}
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/core-platform
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/localization
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/pre-commit-quality-review
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/ui-work
-12
View File
@@ -1,12 +0,0 @@
name = "implementer"
description = "Use for scoped feature work, bug fixes, refactors, and focused test updates after the requirements are clear."
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
sandbox_mode = "workspace-write"
developer_instructions = """
Implement the requested change in the smallest appropriate scope. Follow the repository
instructions in AGENTS.md and the linked .agents documentation. Preserve existing
lifecycle and serialization contracts, and keep unrelated working-tree changes
untouched. Trace the affected call path before editing, update focused tests when
behavior changes, and report the files changed plus the verification actually run.
"""
-11
View File
@@ -1,11 +0,0 @@
name = "researcher"
description = "Use for fast read-only codebase exploration, call-path tracing, and locating relevant tests before implementation."
model = "gpt-5.6-luna"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Trace the actual call path instead of stopping at the first matching file. Identify
the relevant symbols, lifecycle owner, cross-platform boundary, existing tests, and
focused verification commands. Do not modify files. Return concise, evidence-based
findings with file paths and line references when available.
"""
-13
View File
@@ -1,13 +0,0 @@
name = "reviewer"
description = "Use after code changes for correctness, regression, lifecycle, contract, and test review."
model = "gpt-5.6-luna"
model_reasoning_effort = "max"
sandbox_mode = "read-only"
developer_instructions = """
Review the current changes without editing files. Inspect the diff, trace the relevant
call paths, and check platform ownership, serialization or IPC contracts, error
handling, and test coverage. Run focused read-only checks when useful. Report
actionable findings first, ordered by severity, with file paths and line references;
state explicitly when no issue is found and note any validation or platform coverage
gaps.
"""
+8 -5
View File
@@ -1,5 +1,8 @@
[agents]
enabled = true
max_concurrent_threads_per_session = 3
default_subagent_model = "gpt-5.6-luna"
default_subagent_reasoning_effort = "high"
[[hooks.PostToolUse]]
matcher = "apply_patch|Bash"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "bash tool/check_comment_density.sh"
timeout = 15
statusMessage = "Checking comment density"
+2
View File
@@ -0,0 +1,2 @@
# freezed emits lines of trailing spaces, and generated code is never edited by hand.
lib/**/generated/** -whitespace
+5 -3
View File
@@ -23,9 +23,11 @@ cask "flclash" do
app "FlClash.app"
postflight do
system_command "xattr",
args: ["-rd", "com.apple.quarantine", "#{appdir}/FlClash.app"]
postflight_steps do
run "/usr/bin/xattr",
args: ["-rd", "com.apple.quarantine", "{{appdir}}/FlClash.app"],
writable_paths: ["FlClash.app"],
writable_base: :appdir
end
uninstall quit: "com.follow.clash"
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
previous_tag="${1:-}"
output="${2:-release.md}"
: > "$output"
append_range() {
local range="$1"
git log --no-merges --pretty=format:'%B' "$range" |
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$output"
}
current_tag=""
while IFS= read -r next_tag; do
if [[ -n "$current_tag" ]]; then
[[ "$current_tag" == "$previous_tag" ]] && break
append_range "$next_tag..$current_tag"
fi
current_tag="$next_tag"
done < <(git tag --merged HEAD --sort=-creatordate)
if [[ -n "$current_tag" && "$current_tag" != "$previous_tag" ]]; then
append_range "$current_tag"
fi
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
generator="$script_dir/generate_release_notes.sh"
temp_dir="$(mktemp -d)"
trap 'rm -rf "$temp_dir"' EXIT
repo="$temp_dir/repo"
git init --quiet --initial-branch=main "$repo"
git -C "$repo" config user.email "release-notes-test@example.com"
git -C "$repo" config user.name "Release notes test"
commit() {
local message="$1"
local date="$2"
GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \
git -C "$repo" commit --allow-empty --quiet --message "$message"
}
commit "Initial release" "2025-01-01T00:00:00Z"
git -C "$repo" tag v1.0.0
commit "First release change" "2025-01-02T00:00:00Z"
commit "Update changelog" "2025-01-03T00:00:00Z"
git -C "$repo" tag v1.1.0
git -C "$repo" branch feature
git -C "$repo" checkout --quiet feature
commit $'Feature branch change\n\nFeature detail' "2025-01-04T00:00:00Z"
git -C "$repo" checkout --quiet main
GIT_AUTHOR_DATE="2025-01-05T00:00:00Z" \
GIT_COMMITTER_DATE="2025-01-05T00:00:00Z" \
git -C "$repo" merge --no-ff --quiet --message "Merge feature" feature
git -C "$repo" tag v1.2.0
expected="$temp_dir/expected.md"
actual="$temp_dir/actual.md"
printf '%s\n' \
"- Feature branch change" \
"" \
"- Feature detail" \
"" \
"- First release change" \
"" > "$expected"
(
cd "$repo"
bash "$generator" v1.0.0 "$actual"
)
diff -u "$expected" "$actual"
printf '%s\n' "stale content" > "$actual"
(
cd "$repo"
bash "$generator" v1.2.0 "$actual"
)
[[ ! -s "$actual" ]]
(
cd "$repo"
bash "$generator" "" "$actual"
)
grep -q -- "- Initial release" "$actual"
+300 -103
View File
@@ -2,25 +2,47 @@ name: build
on:
push:
branches:
- '**'
tags:
- 'v*'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }}
env:
IS_STABLE: ${{ !contains(github.ref, '-') }}
FLUTTER_VERSION: '3.44.4'
FLUTTER_VERSION: '3.47.4'
GO_VERSION: '1.26.4'
NDK_VERSION: r28c
jobs:
test:
name: Test
dart:
name: Dart
# A private copy pays for its Actions minutes, so it runs the checks only when
# started by hand.
if: &checks github.event_name == 'workflow_dispatch' || !github.event.repository.private
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
submodules: true
- name: Test release notes generation
shell: bash
run: bash .github/scripts/generate_release_notes_test.sh
- name: Test commit message hook
run: bash tool/check_commit_msg_test.sh
- name: Test comment density hook
run: bash tool/check_comment_density_test.sh
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: plugins/rust_api/rust
cache-directories: .dart_tool/hooks_runner
- name: Setup Flutter
uses: subosito/flutter-action@v2
@@ -32,46 +54,131 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Check format
run: dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart
- name: Analyze
run: flutter analyze --no-fatal-infos
- name: Skip the Go core build hook
run: |
yq -i '.hooks.user_defines.setup.build_assets = false' pubspec.yaml
- name: Verify changelog
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
run: dart run tool/changelog.dart verify
- name: Run tests
run: flutter test --reporter expanded
run: flutter test --reporter expanded --coverage
- name: Check coverage
run: dart run tool/check_coverage.dart coverage/lcov.info 75
plugins:
name: Plugins
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Validate local Flutter packages
shell: bash
run: |
for package in plugins/proxy plugins/wifi_ssid plugins/window_ext plugins/setup; do
(
cd "$package"
flutter pub get
flutter analyze --no-fatal-infos
)
done
run: bash tool/check_plugins.sh
- name: Run local plugin tests
shell: bash
run: |
(
cd plugins/proxy
flutter test --reporter expanded
)
(
cd plugins/wifi_ssid
flutter test --reporter expanded
)
- name: Validate setup build tool
working-directory: plugins/setup/buildkit/build_tool
- name: Validate setup hooks package
working-directory: plugins/setup/setup_hooks
run: |
dart pub get
dart analyze
dart test
desktop:
name: Desktop ${{ matrix.platform }}
if: *checks
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux
os: ubuntu-latest
- platform: windows
os: windows-2022
- platform: macos
os: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.4'
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: core/go.sum
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
key: desktop-${{ matrix.platform }}
workspaces: |
services/helper
plugins/rust_api/rust
cache-directories: .dart_tool/hooks_runner
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Linux build dependencies
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libsecret-1-dev
- name: Setup Xcode
if: matrix.platform == 'macos'
run: |
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
xcodebuild -version
- name: Enable git long paths (Windows)
if: matrix.platform == 'windows'
run: git config --global core.longpaths true
- name: Install dependencies
run: flutter pub get
# Compiles the desktop runners and the native plugin code that only the
# tagged release matrix built before; the bundle itself is discarded.
- name: Build debug bundle
run: flutter build ${{ matrix.platform }} --debug
go:
name: Go core
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: core/go.sum
- name: Validate Go core wrapper
@@ -79,23 +186,126 @@ jobs:
env:
CGO_ENABLED: 0
run: |
go test .
if ! compgen -G '*_test.go' > /dev/null; then
echo 'No Go tests found in core/; the test step would pass vacuously.' >&2
exit 1
fi
test -z "$(gofmt -l ./*.go ./platform ./tun)" || {
gofmt -l ./*.go ./platform ./tun >&2
exit 1
}
go test -count=1 .
go vet .
GOOS=linux go vet ./platform
- name: Validate Rust components
- name: Setup NDK
uses: nttld/setup-ndk@v1
id: ndk
with:
ndk-version: ${{ env.NDK_VERSION }}
- name: Validate the Android core library
working-directory: core
env:
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: |
# Nothing else compiles the `android && cgo` files before a `v*` tag.
CC="$(echo "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/aarch64-linux-android21-clang)"
test -x "$CC" || { echo "NDK clang not found at $CC" >&2; exit 1; }
tags="$(sed -n 's/^tags: *//p' ../build_config.yaml)"
test -n "$tags" || { echo 'Could not read tags from build_config.yaml' >&2; exit 1; }
export CGO_ENABLED=1 GOOS=android GOARCH=arm64 CC
go vet "-tags=$tags" . ./tun ./platform
android:
name: Android unit tests
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Setup NDK
uses: nttld/setup-ndk@v1
id: ndk
with:
ndk-version: ${{ env.NDK_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: Run Android unit tests
working-directory: android
env:
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: |
{
echo "flutter.sdk=$FLUTTER_ROOT"
echo "sdk.dir=$ANDROID_HOME"
} > local.properties
wrapper="$FLUTTER_ROOT/bin/cache/artifacts/gradle_wrapper"
test -d "$wrapper" || flutter precache
cp -R "$wrapper/." .
chmod +x gradlew
# `:core` only calls System.loadLibrary; the excluded Flutter tasks would run
# the native build hooks for every debug ABI, which no JVM unit test consumes.
./gradlew :common:testDebugUnitTest :service:testDebugUnitTest :app:testDebugUnitTest \
-x :app:compileFlutterBuildDebug -x :app:copyFlutterAssetsDebug
rust:
name: Rust
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: |
services/helper
plugins/rust_api/rust
- name: Check format
run: |
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
cargo test --manifest-path services/helper/Cargo.toml
cargo fmt --manifest-path plugins/rust_api/rust/Cargo.toml -- --check
- name: Run tests
run: |
cargo test --manifest-path services/helper/Cargo.toml
cargo test --manifest-path plugins/rust_api/rust/Cargo.toml
windows-helper-test:
name: Windows helper test
if: *checks
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: services/helper
- name: Check format
run: cargo fmt --manifest-path services/helper/Cargo.toml -- --check
@@ -103,8 +313,9 @@ jobs:
run: cargo test --manifest-path services/helper/Cargo.toml --features windows-service
build:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: [ test, windows-helper-test ]
# Releases publish where the release links point: the public repository.
if: github.repository == 'chen08209/FlClash' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: [ dart, plugins, go, android, rust, windows-helper-test ]
runs-on: ${{ matrix.os }}
strategy:
matrix:
@@ -159,14 +370,26 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.4'
cache-dependency-path: |
core/go.sum
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: core/go.sum
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.platform }}-${{ matrix.arch || 'all' }}
workspaces: services/helper
cache-directories: .dart_tool/hooks_runner
- name: Setup Gradle
if: startsWith(matrix.platform,'android')
uses: gradle/actions/setup-gradle@v6
- name: Enable git long paths (Windows)
if: startsWith(matrix.os, 'windows')
run: git config --global core.longpaths true
# Stable publishes no Windows or Linux arm64 archive; master makes the action
# clone the SDK at the pinned tag and let it fetch the host's own Dart SDK.
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
@@ -183,8 +406,9 @@ jobs:
- name: Setup NDK
if: startsWith(matrix.platform,'android')
uses: nttld/setup-ndk@v1
id: build-ndk
with:
ndk-version: r28c
ndk-version: ${{ env.NDK_VERSION }}
add-to-path: true
link-to-sdk: true
@@ -193,7 +417,14 @@ jobs:
flutter --version
flutter pub get
# See .agents/flutter-upgrade.md before bumping FLUTTER_VERSION.
- name: Disable Impeller on Intel macOS
if: matrix.platform == 'macos' && matrix.arch == 'amd64'
run: plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist
- name: Setup
env:
ANDROID_NDK_HOME: ${{ steps.build-ndk.outputs.ndk-path }}
run: dart setup.dart ${{ matrix.platform }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }} -v
- name: Upload
@@ -203,61 +434,6 @@ jobs:
path: ./dist
overwrite: true
changelog:
if: ${{ !contains(github.ref, '-') }}
permissions:
contents: write
runs-on: ubuntu-latest
needs: [ build ]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: refs/heads/main
- name: Generate
run: |
last_ver=$(grep -m1 '^## ' CHANGELOG.md 2>/dev/null | sed 's/^## //')
mapfile -t tags < <(git tag --merged HEAD --sort=-creatordate)
temp="NEW_CHANGELOG.md"
: > "$temp"
for i in "${!tags[@]}"; do
curr="${tags[i]}"
[[ "$curr" == "$last_ver" ]] && break
prev="${tags[i+1]}"
range="${prev:+$prev..}$curr"
echo -e "## $curr\n" >> "$temp"
git log --no-merges --pretty=format:"%B" "$range" | \
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$temp"
done
[ -f CHANGELOG.md ] && cat CHANGELOG.md >> "$temp"
mv "$temp" CHANGELOG.md
- name: Commit
run: |
git add CHANGELOG.md
if ! git diff --cached --quiet; then
echo "Commit pushing"
git config --local user.email "chen08209@gmail.com"
git config --local user.name "chen08209"
git commit -m "Update changelog"
if git push; then
echo "Push succeeded"
else
echo "Push failed"
exit 1
fi
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
upload:
permissions:
contents: write
@@ -284,14 +460,35 @@ jobs:
pattern: artifact-*
merge-multiple: true
- name: Generate release.md
shell: bash
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Skip native build hooks
run: |
pre_tag=$(
curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" |
sed -nE 's/.*"tag_name": "([^"]+)".*/\1/p'
)
bash .github/scripts/generate_release_notes.sh "$pre_tag" release.md
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false' pubspec.yaml
- name: Generate release notes
shell: bash
env:
IS_STABLE: ${{ env.IS_STABLE }}
TAG: ${{ github.ref_name }}
run: |
target=()
if [[ "$IS_STABLE" == 'true' ]]; then
target=(--tag "$TAG")
else
dart run tool/changelog.dart build --unreleased
fi
dart run tool/changelog.dart render release "${target[@]}" --out release.md
dart run tool/changelog.dart render telegram "${target[@]}" --out telegram.md
- name: Push to telegram
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
+81
View File
@@ -0,0 +1,81 @@
name: pages
on:
push:
branches:
- main
paths:
- 'site/**'
- 'tool/build_site.sh'
- 'tool/render_site.mjs'
- '.github/workflows/pages.yaml'
workflow_run:
workflows:
- build
types:
- completed
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
tag:
# head_branch also names a branch push's branch, so only a tag at the built commit counts as a release.
if: >-
github.repository == 'chen08209/FlClash' &&
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
startsWith(github.event.workflow_run.head_branch, 'v') &&
!contains(github.event.workflow_run.head_branch, '-')
runs-on: ubuntu-latest
outputs:
stable: ${{ steps.lookup.outputs.stable }}
steps:
- name: Look up the tag
id: lookup
env:
NAME: ${{ github.event.workflow_run.head_branch }}
SHA: ${{ github.event.workflow_run.head_sha }}
REMOTE: ${{ github.server_url }}/${{ github.repository }}
run: |
if git ls-remote --tags "$REMOTE" "refs/tags/$NAME" "refs/tags/$NAME^{}" | grep -q "^$SHA"; then
echo 'stable=true' >> "$GITHUB_OUTPUT"
fi
deploy:
needs: tag
if: >-
github.repository == 'chen08209/FlClash' &&
!cancelled() &&
(github.event_name != 'workflow_run' || needs.tag.outputs.stable == 'true')
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Assemble site
env:
GH_TOKEN: ${{ github.token }}
run: bash tool/build_site.sh _site
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload
uses: actions/upload-pages-artifact@v3
with:
path: _site
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
+2 -1
View File
@@ -43,6 +43,7 @@ app.*.map.json
# Claude Code - ignore user-specific settings only
.claude/settings.local.json
.claude/worktrees/
# Documentation (generated/planning artifacts)
docs/
@@ -68,8 +69,8 @@ docs/
/macos/build/
/env.json
/core_sha256.json
/core/*.exe
/plugins/rust_api/.claude/
/issue-triage/
devtools_options.yaml
# FVM Version Cache
+12
View File
@@ -6,9 +6,21 @@ repos:
entry: dart format
language: system
files: \.dart$
stages: [pre-commit]
- id: comment-density
name: comment density
entry: tool/check_comment_density.sh
language: system
files: \.(dart|kt|kts|swift|go|rs|java|cpp|cc|h|hpp|gradle|ya?ml)$
stages: [pre-commit]
- id: flutter-analyze
name: flutter analyze (pre-push)
entry: flutter analyze --no-fatal-infos
language: system
pass_filenames: false
stages: [pre-push]
- id: commit-msg-format
name: conventional commit message
entry: tool/check_commit_msg.sh
language: system
stages: [commit-msg]
+7
View File
@@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="main.dart (safe mode)" type="FlutterRunConfigurationType" factoryName="Flutter">
<option name="additionalArgs" value="--dart-define-from-file env.json --dart-define=SAFE_MODE=true" />
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
<method v="2" />
</configuration>
</component>
+24
View File
@@ -0,0 +1,24 @@
[
{
"label": "Flutter App (With Env)",
"adapter": "Dart",
"type": "flutter",
"request": "launch",
"program": "lib/main.dart",
"cwd": "$ZED_WORKTREE_ROOT",
"toolArgs": ["--dart-define-from-file", "env.json"],
},
{
"label": "Flutter App (Safe Mode)",
"adapter": "Dart",
"type": "flutter",
"request": "launch",
"program": "lib/main.dart",
"cwd": "$ZED_WORKTREE_ROOT",
"toolArgs": [
"--dart-define-from-file",
"env.json",
"--dart-define=SAFE_MODE=true",
],
},
]
+21
View File
@@ -0,0 +1,21 @@
[
{
"label": "flutter run (With Env)",
"command": "flutter",
"args": ["run", "--dart-define-from-file", "env.json"],
"cwd": "$ZED_WORKTREE_ROOT",
"use_new_terminal": true,
},
{
"label": "flutter run (Safe Mode)",
"command": "flutter",
"args": [
"run",
"--dart-define-from-file",
"env.json",
"--dart-define=SAFE_MODE=true",
],
"cwd": "$ZED_WORKTREE_ROOT",
"use_new_terminal": true,
},
]
+31 -11
View File
@@ -17,6 +17,9 @@ Read these only when the task touches their area:
local plugins.
- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,
command rules, and hooks.
- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.
- [.agents/flutter-upgrade.md](.agents/flutter-upgrade.md): SDK-pinned workarounds to recheck before changing the
Flutter version.
- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.
## Highest Priority Rules
@@ -24,12 +27,21 @@ Read these only when the task touches their area:
- When the user explicitly requests a scoped, low-risk change, inspect the relevant context and implement it directly.
Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation.
Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result.
- Do not add code or configuration comments unless the user explicitly asks for comments. This includes explanatory,
narrative, TODO, and documentation comments. Never annotate line by line; comments belong only at the few key points
that cannot be understood without one, and there you must propose the exact text and wait for approval. Delete
commented-out code and stale notes whenever you touch the surrounding code. Put assertable behavior in a test,
repository-wide invariants in `.agents/`, and keep a comment only for a fact that is local to one call site.
See [.agents/rules.md](.agents/rules.md) for the full policy.
- The request sets the scope. A pre-existing bug, cleanup, or refactor the task did not ask for is a follow-up in the
summary, not part of the change. Edit a file surgically when a rewrite would give the same result, keep scratch checks
out of the repository, and commit tests only where the task asks for them or the neighbouring code already keeps them.
- Comments are allowed and sometimes necessary; excess is the problem. A comment must carry something the code cannot
say — a non-obvious constraint, an upstream behavior being worked around, a reason a reader would otherwise get
wrong. Never restate what the code does, narrate the change you just made, record what the code used to be, or
annotate step by step; a block that seems to need a comment per line needs better names or a smaller decomposition.
Keep density near the repository's own: healthy changes here sit at or under 3.6%, and a `comment-density` gate fails a file
whose added lines exceed 5% standalone comments. Delete commented-out code and stale notes in files you already
touch. Preserve
`// ignore:`-style directives, license headers, codegen markers, and vendored upstream comments. See
[.agents/rules.md](.agents/rules.md) for what belongs in a test or in `.agents/` instead.
- Start FlClash on the host only as a safe mode build, and end only processes you started, by recorded pid. Never
kill a FlClash instance by name; the user's own instance is not yours to close. See
[.agents/rules.md](.agents/rules.md).
- Use `flutter test`, not `dart test`, because models pull in Flutter types.
- Run code generation after modifying models, providers, or database schema.
- Do not manually edit generated files.
@@ -39,12 +51,20 @@ Read these only when the task touches their area:
- Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while
native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded
requests.
- Follow `analysis_options.yaml`, especially single quotes, trailing commas, `child:` last, no `print()`, const/final
- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to.
The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules.
- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/common/shape.dart`,
never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or
`InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of
[.agents/rules.md](.agents/rules.md).
- Follow `lint_options.yaml` (included by every `analysis_options.yaml`), especially single quotes, trailing commas, `child:` last, no `print()`, const/final
preferences, and declared return types.
- For CI parity, verify with `flutter pub get`, `flutter analyze --no-fatal-infos`, and
`flutter test --reporter expanded` when practical.
- Before reporting a change done, run `flutter pub get`, `flutter analyze --no-fatal-infos`, and the `flutter test`
suites that cover it, as CI does. If a toolchain the build hook needs is missing (Go, and on Linux and Windows
cargo), say which check was skipped rather than dropping it silently.
## Repo Skills
Use repo skills from `.agents/skills/` when a task matches their descriptions. Current skills cover localization,
provider tests, UI work, and core/platform changes.
Use repo skills from `.agents/skills/` when a task matches their descriptions; `.claude/skills/` symlinks expose the
same skills to Claude Code. Current skills cover localization, UI work, core/platform changes, and pre-commit
structural quality review.
+150 -413
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -1,4 +0,0 @@
# CLAUDE.md
This repository uses [AGENTS.md](AGENTS.md) as the canonical agent entry point.
Read that file first, then follow the `.agents/` references it routes to.
+5 -28
View File
@@ -1,35 +1,12 @@
SHELL := /bin/bash
PLATFORM ?= macos
BUILDKIT := plugins/setup/buildkit/run_build_tool.sh
ARCH_ARG := $(if $(ARCH),--arch $(ARCH),)
TARGET_PLATFORM_ARG := $(if $(TARGET_PLATFORM),--target-platform $(TARGET_PLATFORM),)
FORCE_ARG := $(if $(filter 1 true yes,$(FORCE)),--force,)
.PHONY: help submodules core core-macos core-linux core-windows core-android
.PHONY: help submodules
help:
@echo 'make core # build macOS core by default'
@echo 'make core PLATFORM=linux ARCH=amd64'
@echo 'make core-macos ARCH=arm64'
@echo 'make core-android ARCH=arm64'
@echo 'make core-android TARGET_PLATFORM=android-arm64'
@echo 'make core-macos FORCE=1 # bypass setup build cache'
@echo 'make submodules # update git submodules (Clash.Meta core sources)'
@echo ''
@echo 'The Go core and Rust helper build automatically through the setup build'
@echo 'hook during flutter build; see .agents/commands.md.'
submodules:
git submodule update --init --recursive
core:
bash $(BUILDKIT) $(PLATFORM) $(ARCH_ARG) $(TARGET_PLATFORM_ARG) $(FORCE_ARG)
core-macos:
$(MAKE) core PLATFORM=macos
core-linux:
$(MAKE) core PLATFORM=linux
core-windows:
$(MAKE) core PLATFORM=windows
core-android:
$(MAKE) core PLATFORM=android
+99 -101
View File
@@ -1,139 +1,137 @@
<div>
<div align="center">
[**简体中文**](README_zh_CN.md)
<img src="assets/images/icon.png" alt="FlClash" width="88">
# FlClash
A multi-platform proxy client based on ClashMeta. Simple to use, open source and ad-free.
**English** · [简体中文](README_zh_CN.md)
[![Release](https://img.shields.io/github/v/release/chen08209/FlClash?style=flat-square&label=release)](https://github.com/chen08209/FlClash/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/chen08209/FlClash/total?style=flat-square&logo=github)](https://github.com/chen08209/FlClash/releases)
[![License](https://img.shields.io/github/license/chen08209/FlClash?style=flat-square)](LICENSE)
[![Telegram](https://img.shields.io/badge/Telegram-channel-26A5E4?style=flat-square&logo=telegram&logoColor=white)](https://t.me/FlClash)
[Website](https://chen08209.github.io/FlClash/) · [Download](#download) · [Changelog](CHANGELOG.md) · [Build from source](#build-from-source)
</div>
## FlClash
[![Downloads](https://img.shields.io/github/downloads/chen08209/FlClash/total?style=flat-square&logo=github)](https://github.com/chen08209/FlClash/releases/)[![Last Version](https://img.shields.io/github/release/chen08209/FlClash/all.svg?style=flat-square)](https://github.com/chen08209/FlClash/releases/)[![License](https://img.shields.io/github/license/chen08209/FlClash?style=flat-square)](LICENSE)
[![Channel](https://img.shields.io/badge/Telegram-Channel-blue?style=flat-square&logo=telegram)](https://t.me/FlClash)
A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
on Desktop:
<p style="text-align: center;">
<img alt="desktop" src="snapshots/desktop.gif">
</p>
on Mobile:
<p style="text-align: center;">
<img alt="mobile" src="snapshots/mobile.gif">
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="snapshots/preview-dark.png">
<img alt="FlClash dashboard on a MacBook and a phone" src="snapshots/preview.png" width="92%">
</picture>
</p>
## Features
✈️ Multi-platform: Android, Windows, macOS and Linux
💻 Adaptive multiple screen sizes, Multiple color themes available
💡 Based on Material You Design, [Surfboard](https://github.com/getsurfboard/surfboard)-like UI
☁️ Supports data sync via WebDAV
✨ Support subscription link, Dark mode
## Use
### Linux
⚠️ Make sure to install the following dependencies before using them
```bash
sudo apt-get install libayatana-appindicator3-dev
sudo apt-get install libkeybinder-3.0-dev
```
### Android
Support the following actions
```bash
com.follow.clash.action.START
com.follow.clash.action.STOP
com.follow.clash.action.TOGGLE
```
- **One app for Android, Windows, macOS and Linux**, with x64 and ARM64 builds on the desktop.
- **mihomo (Clash.Meta) core** with rule routing, proxy groups, latency tests, system proxy and TUN mode.
- **Profiles** from a subscription link or a file, with a built-in editor, override scripts, and custom rules,
proxies and proxy groups.
- **Live views** of connections, requests, DNS queries and logs.
- **Material You design** with dynamic color, light and dark themes, and layouts that adapt from phones to desktops.
- **Backup and restore** through WebDAV or a local file.
- **Platform touches**: a Quick Settings tile, per-app proxy and Android TV support on Android; a tray menu and
global hotkeys on the desktop.
- **Open source and ad-free**, licensed under GPL-3.0.
## Download
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
Get the latest build from [GitHub Releases](https://github.com/chen08209/FlClash/releases/latest), or open the
[website](https://chen08209.github.io/FlClash/#download), which picks the right file for your device.
### Homebrew
| Platform | Packages | Notes |
| --- | --- | --- |
| Android | APK for `arm64-v8a`, `armeabi-v7a` and `x86_64` | Most phones use `arm64-v8a`. Also on the F-Droid repository below. |
| Windows 10 and later | Installer (`.exe`) or portable `.zip`, for x64 and ARM64 | Pick ARM64 on Snapdragon and other ARM laptops. |
| macOS 12 and later | DMG for Apple Silicon and Intel | Also on Homebrew. |
| Linux | `.deb`, `.rpm` and AppImage, for x64 and ARM64 | See the tray note below. |
<p>
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" height="56"></a>
<a href="https://github.com/chen08209/FlClash/releases/latest"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" height="56"></a>
</p>
**Homebrew**
```bash
brew tap chen08209/tap
brew install --cask flclash
```
## Build
**Linux tray icon**
1. Update submodules
```bash
git submodule update --init --recursive
```
The `.deb` package installs its own dependencies. With the AppImage or the `.rpm`, install the AyatanaAppIndicator
library so the tray icon can show:
2. Install `Flutter` and `Golang` environment
```bash
sudo apt-get install libayatana-appindicator3-1 # Debian and Ubuntu
sudo dnf install libayatana-appindicator-gtk3 # Fedora
```
3. Build Application
## Usage
- android
**Import a profile from a link.** Opening a link in this form imports the subscription into FlClash. The `clashmeta://`
and `flclash://` schemes work the same way.
1. Install `Android SDK`, `Android NDK`
```text
clash://install-config?url=<URL-encoded subscription link>
```
2. Set `ANDROID_NDK` environment variable
**Automate on Android.** Tasker, MacroDroid and similar apps can start, stop or toggle the proxy by starting an
activity with one of these actions:
3. Run build script
```text
com.follow.clash.action.START
com.follow.clash.action.STOP
com.follow.clash.action.TOGGLE
```
```bash
dart setup.dart android
```
From a computer, the same works over adb:
- windows
```bash
adb shell am start -a com.follow.clash.action.TOGGLE
```
1. Requires a Windows client
## Build from source
2. Install `GCC`, `Inno Setup`
You need [Flutter](https://docs.flutter.dev/get-started/install) 3.47 (release builds use 3.47.4),
[Go](https://go.dev/dl/) 1.26 and [Rust](https://rustup.rs/) installed through rustup. Each platform builds on its own
host, except Android, which builds anywhere.
3. Run build script
```bash
git clone --recursive https://github.com/chen08209/FlClash.git
cd FlClash
flutter pub get
dart setup.dart android # or windows, macos, linux
```
```bash
dart setup.dart windows
```
Packages land in `dist/`. The Go core and the Rust libraries are compiled as part of the Flutter build.
- linux
| Platform | Also needed |
| --- | --- |
| Android | Android SDK with the NDK. Add `--arch arm64` to build a single ABI. |
| Windows | GCC (MinGW-w64) for the core and [Inno Setup](https://jrsoftware.org/isinfo.php) 6 for the installer. |
| macOS | Xcode and Node.js. The script installs `appdmg` through npm. |
| Linux | Debian or Ubuntu. The script installs the build packages with apt and downloads `appimagetool`. |
1. Requires a Linux client
Run `dart setup.dart --help` for the remaining options, such as `--targets` to build only some package formats.
2. Dependencies are auto-installed by setup script, or manually:
```bash
sudo apt-get install -y libayatana-appindicator3-dev libkeybinder-3.0-dev
```
## Support
3. Run build script
Starring the repository is the easiest way to support the project. Questions and announcements go to the
[Telegram channel](https://t.me/FlClash); bugs and feature requests go to
[GitHub Issues](https://github.com/chen08209/FlClash/issues).
```bash
dart setup.dart linux
```
<a href="https://star-history.com/#chen08209/FlClash&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date&theme=dark">
<img alt="Star history" src="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date" width="640">
</picture>
</a>
- macOS
## License
1. Requires a macOS client
2. Run build script
```bash
dart setup.dart macos
```
## Star
The easiest way to support developers is to click on the star (⭐) at the top of the page.
<p style="text-align: center;">
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
</a>
</p>
FlClash is released under the [GPL-3.0 license](LICENSE).
+96 -105
View File
@@ -1,139 +1,130 @@
<div>
<div align="center">
[**English**](README.md)
<img src="assets/images/icon.png" alt="FlClash" width="88">
# FlClash
基于 ClashMeta 的多平台代理客户端,简单易用,开源无广告。
[English](README.md) · **简体中文**
[![Release](https://img.shields.io/github/v/release/chen08209/FlClash?style=flat-square&label=release)](https://github.com/chen08209/FlClash/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/chen08209/FlClash/total?style=flat-square&logo=github)](https://github.com/chen08209/FlClash/releases)
[![License](https://img.shields.io/github/license/chen08209/FlClash?style=flat-square)](LICENSE)
[![Telegram](https://img.shields.io/badge/Telegram-channel-26A5E4?style=flat-square&logo=telegram&logoColor=white)](https://t.me/FlClash)
[官网](https://chen08209.github.io/FlClash/zh/) · [下载](#下载) · [更新日志](CHANGELOG.md) · [从源码构建](#从源码构建)
</div>
## FlClash
[![Downloads](https://img.shields.io/github/downloads/chen08209/FlClash/total?style=flat-square&logo=github)](https://github.com/chen08209/FlClash/releases/)[![Last Version](https://img.shields.io/github/release/chen08209/FlClash/all.svg?style=flat-square)](https://github.com/chen08209/FlClash/releases/)[![License](https://img.shields.io/github/license/chen08209/FlClash?style=flat-square)](LICENSE)
[![Channel](https://img.shields.io/badge/Telegram-Channel-blue?style=flat-square&logo=telegram)](https://t.me/FlClash)
基于ClashMeta的多平台代理客户端,简单易用,开源无广告。
on Desktop:
<p style="text-align: center;">
<img alt="desktop" src="snapshots/desktop.gif">
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="snapshots/preview-dark.png">
<img alt="FlClash 在 MacBook 与手机上的仪表盘" src="snapshots/preview.png" width="92%">
</picture>
</p>
on Mobile:
<p style="text-align: center;">
<img alt="mobile" src="snapshots/mobile.gif">
## 功能
- **一个应用覆盖 Android、Windows、macOS 和 Linux**,桌面端同时提供 x64 与 ARM64 版本。
- **mihomo(Clash.Meta)内核**:规则分流、代理组、延迟测试、系统代理与 TUN 模式。
- **配置管理**:通过订阅链接或文件导入配置,内置编辑器,支持覆写脚本以及自定义规则、代理和代理组。
- **实时查看**连接、请求、DNS 查询和日志。
- **Material You 设计**:支持动态取色、浅色与深色主题,布局随屏幕从手机到桌面自适应。
- **备份与恢复**:通过 WebDAV 或本地文件。
- **平台细节**:Android 上有快捷设置磁贴、分应用代理和 Android TV 支持;桌面端有托盘菜单和全局快捷键。
- **开源无广告**,以 GPL-3.0 协议发布。
## 下载
从 [GitHub Releases](https://github.com/chen08209/FlClash/releases/latest) 获取最新版本,或打开
[官网](https://chen08209.github.io/FlClash/zh/#download),它会自动为你的设备选好安装包。
| 平台 | 安装包 | 说明 |
| --- | --- | --- |
| Android | `arm64-v8a`、`armeabi-v7a`、`x86_64` 三种 APK | 绝大多数手机选 `arm64-v8a`。也可以通过下方的 F-Droid 仓库安装。 |
| Windows 10 及以上 | 安装版(`.exe`)或便携版(`.zip`),分 x64 与 ARM64 | 骁龙等 ARM 架构笔记本选 ARM64。 |
| macOS 12 及以上 | Apple Silicon 与 Intel 两种 DMG | 也可以通过 Homebrew 安装。 |
| Linux | `.deb`、`.rpm` 和 AppImage,分 x64 与 ARM64 | 托盘依赖见下方说明。 |
<p>
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" height="56"></a>
<a href="https://github.com/chen08209/FlClash/releases/latest"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" height="56"></a>
</p>
## Features
✈️ 多平台: Android, Windows, macOS and Linux
💻 自适应多个屏幕尺寸,多种颜色主题可供选择
💡 基本 Material You 设计, 类[Surfboard](https://github.com/getsurfboard/surfboard)用户界面
☁️ 支持通过WebDAV同步数据
✨ 支持一键导入订阅, 深色模式
## Use
### Linux
⚠️ 使用前请确保安装以下依赖
```bash
sudo apt-get install libayatana-appindicator3-dev
sudo apt-get install libkeybinder-3.0-dev
```
### Android
支持下列操作
```bash
com.follow.clash.action.START
com.follow.clash.action.STOP
com.follow.clash.action.TOGGLE
```
## Download
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
### Homebrew
**Homebrew**
```bash
brew tap chen08209/tap
brew install --cask flclash
```
## Build
**Linux 托盘图标**
1. 更新 submodules
```bash
git submodule update --init --recursive
```
`.deb` 安装包会自动安装所需依赖。使用 AppImage 或 `.rpm` 时,需要先安装 AyatanaAppIndicator 库,托盘图标才能显示:
2. 安装 `Flutter` 以及 `Golang` 环境
```bash
sudo apt-get install libayatana-appindicator3-1 # Debian 与 Ubuntu
sudo dnf install libayatana-appindicator-gtk3 # Fedora
```
3. 构建应用
## 使用
- android
**通过链接导入配置。** 打开下面格式的链接,即可把订阅导入 FlClash。`clashmeta://` 和 `flclash://` 开头的链接同样可用。
1. 安装 `Android SDK` , `Android NDK`
```text
clash://install-config?url=<经过 URL 编码的订阅链接>
```
2. 设置 `ANDROID_NDK` 环境变量
**Android 自动化。** Tasker、MacroDroid 等应用可以用下面的 Action 启动 Activity,从而启动、停止或切换代理:
3. 运行构建脚本
```text
com.follow.clash.action.START
com.follow.clash.action.STOP
com.follow.clash.action.TOGGLE
```
```bash
dart setup.dart android
```
在电脑上也可以通过 adb 触发:
- windows
```bash
adb shell am start -a com.follow.clash.action.TOGGLE
```
1. 你需要一个windows客户端
## 从源码构建
2. 安装 `GCC`,`Inno Setup`
需要 [Flutter](https://docs.flutter.dev/get-started/install) 3.47(正式版构建使用 3.47.4)、[Go](https://go.dev/dl/) 1.26,
以及通过 rustup 安装的 [Rust](https://rustup.rs/)。桌面端需要在对应系统上构建,Android 在任意系统上都能构建。
3. 运行构建脚本
```bash
git clone --recursive https://github.com/chen08209/FlClash.git
cd FlClash
flutter pub get
dart setup.dart android # 或 windows、macos、linux
```
```bash
dart setup.dart windows
```
安装包输出到 `dist/`。Go 内核和 Rust 库会在 Flutter 构建过程中一并编译。
- linux
| 平台 | 额外依赖 |
| --- | --- |
| Android | 带 NDK 的 Android SDK。加上 `--arch arm64` 可以只构建单个 ABI。 |
| Windows | 编译内核用的 GCC(MinGW-w64),以及打包安装程序用的 [Inno Setup](https://jrsoftware.org/isinfo.php) 6。 |
| macOS | Xcode 与 Node.js。脚本会通过 npm 安装 `appdmg`。 |
| Linux | Debian 或 Ubuntu。脚本会用 apt 安装构建所需的软件包,并下载 `appimagetool`。 |
1. 你需要一个linux客户端
其余选项可以运行 `dart setup.dart --help` 查看,例如用 `--targets` 只构建部分安装包格式。
2. 依赖会由 setup 脚本自动安装,也可以手动安装:
```bash
sudo apt-get install -y libayatana-appindicator3-dev libkeybinder-3.0-dev
```
## 支持
3. 运行构建脚本
给仓库点一个 Star 是支持项目最简单的方式。问题讨论和更新通知请关注 [Telegram 频道](https://t.me/FlClash),
Bug 与功能建议请提交到 [GitHub Issues](https://github.com/chen08209/FlClash/issues)。
```bash
dart setup.dart linux
```
<a href="https://star-history.com/#chen08209/FlClash&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date&theme=dark">
<img alt="Star history" src="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date" width="640">
</picture>
</a>
- macOS
## 许可证
1. 你需要一个macOS客户端
2. 运行构建脚本
```bash
dart setup.dart macos
```
## Star
支持开发者的最简单方式是点击页面顶部的星标(⭐)。
<p style="text-align: center;">
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
</a>
</p>
FlClash 以 [GPL-3.0 协议](LICENSE)发布。
+8 -35
View File
@@ -1,43 +1,16 @@
include: package:flutter_lints/flutter.yaml
include: lint_options.yaml
analyzer:
exclude:
- build/**
- lib/l10n/intl/**
- lib/**/generated/**
- plugins/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
errors:
invalid_annotation_target: ignore
linter:
rules:
# Style
prefer_single_quotes: true
require_trailing_commas: true
sort_child_properties_last: true
# Avoid unnecessary code
unnecessary_new: true
unnecessary_this: true
unnecessary_const: true
avoid_print: true
avoid_unnecessary_containers: true
sized_box_for_whitespace: true
# Prefer best practices
prefer_const_constructors: true
prefer_const_declarations: true
prefer_final_locals: true
prefer_final_in_for_each: true
prefer_is_empty: true
prefer_is_not_empty: true
prefer_interpolation_to_compose_strings: true
# Type safety
always_declare_return_types: true
annotate_overrides: true
use_key_in_widget_constructors: true
no_leading_underscores_for_local_identifiers: true
# Async
use_build_context_synchronously: true
unnecessary_await_in_return: true
+19
View File
@@ -1,3 +1,4 @@
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
@@ -79,8 +80,17 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
configure<CrashlyticsExtension> {
nativeSymbolUploadEnabled = hasReleaseSigning
}
}
}
sourceSets {
// Unit tests live under android/tests/ instead of each module's src/test.
getByName("test").java.setSrcDirs(listOf("../tests/app"))
}
}
kotlin {
@@ -93,6 +103,13 @@ flutter {
source = "../.."
}
// The Crashlytics plugin finalizes R8 with the mapping upload but leaves the native symbol upload to the caller.
if (hasReleaseSigning) {
tasks.matching { it.name == "assembleRelease" || it.name == "bundleRelease" }.configureEach {
finalizedBy("uploadCrashlyticsSymbolFileRelease")
}
}
dependencies {
implementation(project(":service"))
implementation(project(":common"))
@@ -105,4 +122,6 @@ dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics.ndk)
implementation(libs.firebase.analytics)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
+2
View File
@@ -23,9 +23,11 @@
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="com.android.permission.GET_INSTALLED_APPS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<application
android:name=".FlClashApplication"
@@ -6,21 +6,18 @@ import android.content.Intent
import android.os.Handler
import android.os.Looper
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.BroadcastLease
import com.follow.clash.common.GlobalState
import com.follow.clash.common.action
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.launch
class ServiceBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val action = intent?.action ?: return
val pendingResult = goAsync()
val finished = AtomicBoolean(false)
val lease = BroadcastLease { pendingResult.finish() }
val timeout = Runnable {
if (finished.compareAndSet(false, true)) {
GlobalState.log("Broadcast handling timed out: $action")
pendingResult.finish()
}
lease.release { GlobalState.log("Broadcast handling timed out: $action") }
}
mainHandler.postDelayed(timeout, BROADCAST_TIMEOUT_MILLIS)
GlobalState.launch {
@@ -30,9 +27,7 @@ class ServiceBroadcastReceiver : BroadcastReceiver() {
GlobalState.log("Unable to handle service broadcast $action: $error")
} finally {
mainHandler.removeCallbacks(timeout)
if (finished.compareAndSet(false, true)) {
pendingResult.finish()
}
lease.release()
}
}
}
@@ -42,9 +42,9 @@ object ServiceController {
binding = null
}
fun invokeMethod(data: String, callback: (String) -> Unit): Result<Unit> = runCatching {
fun invokeMethod(data: String, callback: (ByteArray) -> Unit): Result<Unit> = runCatching {
Core.invokeMethod(data) { result ->
callback(result.orEmpty())
callback(result ?: ByteArray(0))
}
}
@@ -72,7 +72,9 @@ object ServiceController {
}
if (binding?.component != nextIntent.component) {
clearBinding()
if (binding != null) {
tearDownServices()
}
lateinit var nextBinding: ManagedServiceBinding
nextBinding = ManagedServiceBinding(nextIntent) { message ->
handleServiceDisconnected(nextBinding, message)
@@ -106,12 +108,29 @@ object ServiceController {
}
suspend fun stop() = lock.withLock {
tearDownServices()
runTimeMillis = 0L
}
private suspend fun tearDownServices() {
binding?.useService { service -> service.stop() }
?.onFailure { error ->
GlobalState.log("Unable to stop background service: $error")
}
clearBinding()
runTimeMillis = 0L
stopServices()
}
// A service the system started itself — always-on VPN, or the sticky restart
// after the process was killed — outlives every binding this process holds, so
// unbinding alone leaves the tunnel up while the app reports it stopped.
private fun stopServices() {
listOf(VpnService::class.intent, ProxyService::class.intent).forEach { intent ->
runCatching { GlobalState.application.stopService(intent) }
.onFailure { error ->
GlobalState.log("Unable to stop ${intent.component?.className}: $error")
}
}
}
suspend fun isVpnServiceActive(): Boolean = lock.withLock {
@@ -1,323 +1,37 @@
package com.follow.clash
import android.net.VpnService
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.TilePlugin
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import com.google.gson.Gson
import io.flutter.embedding.engine.FlutterEngine
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
enum class RunState {
STARTED,
STARTING,
STOPPING,
STOPPED,
}
private const val MISSING_CONFIG_MESSAGE = "No configuration found."
private const val INVALID_CONFIG_MESSAGE = "Invalid configuration."
private const val VPN_PERMISSION_MESSAGE = "VPN permission required."
private const val START_FAILED_MESSAGE = "Failed to start service."
object ServiceState {
private val transitionLock = Mutex()
private val startPreparationLock = Mutex()
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
private val latestRequest = AtomicReference(RunRequest(running = false))
private val machine = ServiceStateMachine(AndroidServiceStateHost)
@Volatile
private var sharedState = SharedState()
val runState = machine.runState
@Volatile
private var flutterEngine: FlutterEngine? = null
fun attachFlutterEngine(engine: FlutterEngine) =
AndroidServiceStateHost.attachFlutterEngine(engine)
val runState = mutableRunState.asStateFlow()
fun detachFlutterEngine(engine: FlutterEngine) =
AndroidServiceStateHost.detachFlutterEngine(engine)
private val runTimeMillis: Long
get() = ServiceController.getRunTimeMillis()
suspend fun handleToggleAction() = machine.handleToggleAction()
private val appPlugin: AppPlugin?
get() = flutterEngine?.plugin<AppPlugin>()
suspend fun handleStartAction() = machine.handleStartAction()
private val tilePlugin: TilePlugin?
get() = flutterEngine?.plugin<TilePlugin>()
suspend fun handleStopAction() = machine.handleStopAction()
fun attachFlutterEngine(engine: FlutterEngine) {
flutterEngine = engine
}
suspend fun handleVpnRevokeAction() = machine.handleVpnRevokeAction()
fun detachFlutterEngine(engine: FlutterEngine) {
if (flutterEngine === engine) {
flutterEngine = null
}
}
suspend fun refresh(): Long = machine.refresh()
suspend fun handleToggleAction() {
if (isRunningRequested()) {
handleStopAction()
} else {
handleStartAction()
}
}
fun requestStart(): Deferred<Boolean> = machine.requestStart()
suspend fun refresh(): Long = transitionLock.withLock {
val current = runTimeMillis
mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED
current
}
fun requestStop(): Deferred<Boolean> = machine.requestStop()
internal fun captureRequestToken(): RunRequest = latestRequest.get()
fun syncSharedState(state: SharedState) = machine.syncSharedState(state)
/**
* Settles the state after the bound service was lost. [token] is the request that was current
* when the loss was observed, so a start that raced ahead of this callback keeps its intent.
*/
internal suspend fun handleServiceLost(token: RunRequest) = transitionLock.withLock {
if (runTimeMillis != 0L) {
return@withLock
}
if (!latestRequest.compareAndSet(token, RunRequest(running = false))) {
return@withLock
}
mutableRunState.value = RunState.STOPPED
}
internal fun captureRequestToken(): RunRequest = machine.captureRequestToken()
suspend fun handleStartAction() {
if (isRunningRequested()) {
return
}
val plugin = tilePlugin
if (plugin != null) {
plugin.handleStart()
return
}
loadPreferencesAndStart()
}
suspend fun handleStopAction() {
if (!isRunningRequested()) {
return
}
val plugin = tilePlugin
if (plugin != null) {
plugin.handleStop()
return
}
GlobalState.application.showToast(sharedState.stopTip)
requestStop().await()
}
suspend fun handleVpnRevokeAction() {
if (!ServiceController.isVpnServiceActive()) {
return
}
handleStopAction()
}
fun requestStart(): Deferred<Boolean> {
val request = createRequest(running = true)
val result = CompletableDeferred<Boolean>()
val launchRequest: (Boolean) -> Unit = { shouldStart ->
if (!shouldStart) {
fail(request)
result.complete(false)
} else {
GlobalState.launch {
result.complete(
runCatching { start(request) }
.onFailure { error ->
GlobalState.log("Unable to process service start request: $error")
fail(request)
}
.getOrDefault(false),
)
}
}
}
val plugin = appPlugin
if (plugin != null) {
plugin.requestNotificationPermission(launchRequest)
} else {
launchRequest(true)
}
return result
}
fun requestStop(): Deferred<Boolean> {
val request = createRequest(running = false)
val result = CompletableDeferred<Boolean>()
GlobalState.launch {
result.complete(
runCatching { stop(request) }
.onFailure { error ->
GlobalState.log("Unable to process service stop request: $error")
}
.getOrDefault(false),
)
}
return result
}
fun syncSharedState(state: SharedState) {
sharedState = state
applySharedState()
}
private suspend fun loadPreferencesAndStart() {
sharedState = GlobalState.application.sharedState
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
GlobalState.application.showToast(MISSING_CONFIG_MESSAGE)
return
}
if (setupCore()) {
if (!requestStart().await()) {
GlobalState.application.showToast(START_FAILED_MESSAGE)
}
}
}
private fun applySharedState() {
GlobalState.setCrashlytics(sharedState.crashlytics)
ServiceConfig.updateNotificationParams(
NotificationParams(
title = sharedState.currentProfileName,
stopText = sharedState.stopText,
onlyStatisticsProxy = sharedState.onlyStatisticsProxy,
),
)
}
private suspend fun setupCore(): Boolean {
applySharedState()
GlobalState.application.showToast(sharedState.startTip)
val initParams = Gson().toJson(
mapOf(
"home-dir" to GlobalState.application.filesDir.path,
"version" to android.os.Build.VERSION.SDK_INT,
),
)
val setupParams = Gson().toJson(sharedState.setupParams)
return ServiceController.quickSetup(
initParams,
setupParams,
).fold(
onSuccess = { message ->
if (message.isEmpty()) {
true
} else {
GlobalState.log("Unable to set up core: $message")
showConfigError(message)
false
}
},
onFailure = { error ->
GlobalState.log("Unable to set up core: $error")
showConfigError(error.message)
false
},
)
}
private fun showConfigError(message: String?) {
GlobalState.application.showToast(
message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE,
)
}
private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock {
if (!isCurrent(request)) {
return@withLock false
}
val options = sharedState.vpnOptions
if (options == null) {
fail(request)
return@withLock false
}
if (!prepareVpn(options)) {
if (appPlugin == null && isCurrent(request)) {
GlobalState.application.showToast(VPN_PERMISSION_MESSAGE)
}
fail(request)
return@withLock false
}
if (!isCurrent(request)) {
return@withLock false
}
transitionLock.withLock transition@{
if (!isCurrent(request)) {
return@transition false
}
if (runState.value == RunState.STARTED && runTimeMillis != 0L) {
return@transition true
}
mutableRunState.value = RunState.STARTING
val startedAtMillis = ServiceController.start(options)
mutableRunState.value =
if (startedAtMillis == 0L) RunState.STOPPED else RunState.STARTED
if (startedAtMillis == 0L) {
fail(request)
return@transition false
}
isCurrent(request)
}
}
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
if (!isCurrent(request)) {
return@withLock false
}
if (runState.value == RunState.STOPPED && runTimeMillis == 0L) {
return@withLock true
}
mutableRunState.value = RunState.STOPPING
ServiceController.stop()
mutableRunState.value = RunState.STOPPED
isCurrent(request)
}
private suspend fun prepareVpn(options: VpnOptions): Boolean {
val plugin = appPlugin
?: return !options.enable || VpnService.prepare(GlobalState.application) == null
return suspendCancellableCoroutine { continuation ->
val callback: (Boolean) -> Unit = { granted ->
if (continuation.isActive) {
continuation.resume(granted)
}
}
continuation.invokeOnCancellation {
plugin.cancelVpnPreparation(callback)
}
plugin.prepareVpn(options.enable, callback)
}
}
private fun createRequest(running: Boolean): RunRequest =
RunRequest(running).also(latestRequest::set)
private fun isRunningRequested(): Boolean = latestRequest.get().running
private fun isCurrent(request: RunRequest): Boolean = latestRequest.get() === request
private fun fail(request: RunRequest) {
latestRequest.compareAndSet(request, RunRequest(running = false))
}
internal class RunRequest(
val running: Boolean,
)
internal suspend fun handleServiceLost(token: RunRequest) = machine.handleServiceLost(token)
}
@@ -0,0 +1,151 @@
package com.follow.clash
import android.Manifest
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import androidx.core.content.ContextCompat
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.TilePlugin
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import io.flutter.embedding.engine.FlutterEngine
import kotlinx.coroutines.CoroutineScope
/**
* The machine owns the arbitration and the transitions; this is only the part that cannot run on a
* plain JVM, so unit tests can drive the state machine with an in-memory implementation.
*/
internal interface ServiceStateHost {
val scope: CoroutineScope
val runTimeMillis: Long
val homeDirPath: String
val sdkInt: Int
fun log(message: String)
fun showToast(message: String)
fun setCrashlytics(enabled: Boolean)
fun updateNotificationParams(params: NotificationParams)
fun loadSharedState(): SharedState
fun isVpnPermissionGranted(): Boolean
fun isLocalNetworkPermissionGranted(): Boolean
fun tile(): TileGateway?
fun app(): AppGateway?
suspend fun quickSetup(initParams: String, setupParams: String): Result<String>
suspend fun startService(options: VpnOptions): Long
suspend fun stopService()
suspend fun isVpnServiceActive(): Boolean
}
internal interface TileGateway {
fun handleStart()
fun handleStop()
}
internal interface AppGateway {
fun requestNotificationPermission(callback: (Boolean) -> Unit)
fun requestLocalNetworkPermission(callback: (Boolean) -> Unit)
fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit)
fun cancelVpnPreparation(callback: (Boolean) -> Unit)
}
internal object AndroidServiceStateHost : ServiceStateHost {
@Volatile
private var flutterEngine: FlutterEngine? = null
override val scope: CoroutineScope
get() = GlobalState
override val runTimeMillis: Long
get() = ServiceController.getRunTimeMillis()
override val homeDirPath: String
get() = GlobalState.application.filesDir.path
override val sdkInt: Int
get() = android.os.Build.VERSION.SDK_INT
fun attachFlutterEngine(engine: FlutterEngine) {
flutterEngine = engine
}
fun detachFlutterEngine(engine: FlutterEngine) {
if (flutterEngine === engine) {
flutterEngine = null
}
}
override fun log(message: String) = GlobalState.log(message)
override fun showToast(message: String) = GlobalState.application.showToast(message)
override fun setCrashlytics(enabled: Boolean) = GlobalState.setCrashlytics(enabled)
override fun updateNotificationParams(params: NotificationParams) =
ServiceConfig.updateNotificationParams(params)
override fun loadSharedState(): SharedState = GlobalState.application.sharedState
override fun isVpnPermissionGranted(): Boolean =
VpnService.prepare(GlobalState.application) == null
override fun isLocalNetworkPermissionGranted(): Boolean =
sdkInt < Build.VERSION_CODES.CINNAMON_BUN ||
ContextCompat.checkSelfPermission(
GlobalState.application,
Manifest.permission.ACCESS_LOCAL_NETWORK,
) == PackageManager.PERMISSION_GRANTED
override fun tile(): TileGateway? = flutterEngine?.plugin<TilePlugin>()?.let { plugin ->
object : TileGateway {
override fun handleStart() = plugin.handleStart()
override fun handleStop() = plugin.handleStop()
}
}
override fun app(): AppGateway? = flutterEngine?.plugin<AppPlugin>()?.let { plugin ->
object : AppGateway {
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
plugin.requestNotificationPermission(callback)
override fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) =
plugin.requestLocalNetworkPermission(callback)
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) =
plugin.prepareVpn(enable, callback)
override fun cancelVpnPreparation(callback: (Boolean) -> Unit) =
plugin.cancelVpnPreparation(callback)
}
}
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> =
ServiceController.quickSetup(initParams, setupParams)
override suspend fun startService(options: VpnOptions): Long =
ServiceController.start(options)
override suspend fun stopService() = ServiceController.stop()
override suspend fun isVpnServiceActive(): Boolean = ServiceController.isVpnServiceActive()
}
@@ -0,0 +1,363 @@
package com.follow.clash
import com.follow.clash.common.RunIntentArbiter
import com.follow.clash.models.SharedState
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import com.google.gson.Gson
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
enum class RunState {
STARTED,
STARTING,
STOPPING,
STOPPED,
}
internal typealias RunRequest = RunIntentArbiter.Token
internal const val MISSING_CONFIG_MESSAGE = "No configuration found."
internal const val INVALID_CONFIG_MESSAGE = "Invalid configuration."
internal const val VPN_PERMISSION_MESSAGE = "VPN permission required."
internal const val START_FAILED_MESSAGE = "Failed to start service."
/**
* Callers request a transition; the newest request always wins. Every step that outlives its own
* suspension point re-checks [isCurrent] before it publishes anything, so a start that was overtaken
* by a stop cannot report itself as started.
*/
internal class ServiceStateMachine(private val host: ServiceStateHost) {
private val transitionLock = Mutex()
private val startPreparationLock = Mutex()
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
private val arbiter = RunIntentArbiter()
@Volatile
private var sharedState = SharedState()
@Volatile
private var pendingVpnPreparation: (() -> Unit)? = null
val runState = mutableRunState.asStateFlow()
private val runTimeMillis: Long
get() = host.runTimeMillis
suspend fun handleToggleAction() {
if (isRunningRequested()) {
handleStopAction()
} else {
handleStartAction()
}
}
suspend fun refresh(): Long = transitionLock.withLock {
val current = runTimeMillis
mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED
current
}
fun captureRequestToken(): RunRequest = arbiter.current()
/**
* Settles the state after the bound service was lost. [token] is the request that was current
* when the loss was observed, so a start that raced ahead of this callback keeps its intent.
*/
suspend fun handleServiceLost(token: RunRequest) = transitionLock.withLock {
if (runTimeMillis != 0L) {
return@withLock
}
if (!arbiter.resetToStopped(token)) {
return@withLock
}
mutableRunState.value = RunState.STOPPED
}
suspend fun handleStartAction() {
if (isRunningRequested()) {
return
}
val tile = host.tile()
if (tile != null) {
tile.handleStart()
return
}
loadPreferencesAndStart()
}
suspend fun handleStopAction() {
if (!isRunningRequested()) {
return
}
val tile = host.tile()
if (tile != null) {
tile.handleStop()
return
}
host.showToast(sharedState.stopTip)
requestStop().await()
}
suspend fun handleVpnRevokeAction() {
if (!host.isVpnServiceActive()) {
return
}
handleStopAction()
}
fun requestStart(): Deferred<Boolean> {
val request = createRequest(running = true)
val result = CompletableDeferred<Boolean>()
val launchRequest: (Boolean) -> Unit = { shouldStart ->
if (!shouldStart) {
fail(request)
result.complete(false)
} else {
host.scope.launch {
result.complete(
runCatching { start(request) }
.onFailure { error ->
host.log("Unable to process service start request: $error")
fail(request)
reconcileStopped()
}
.getOrDefault(false),
)
}
}
}
val app = host.app()
if (app != null) {
app.requestNotificationPermission { shouldStart ->
if (shouldStart) {
app.requestLocalNetworkPermission { launchRequest(true) }
} else {
launchRequest(false)
}
}
} else {
launchRequest(true)
}
return result
}
fun requestStop(): Deferred<Boolean> {
val request = createRequest(running = false)
val result = CompletableDeferred<Boolean>()
host.scope.launch {
result.complete(
runCatching { stop(request) }
.onFailure { error ->
host.log("Unable to process service stop request: $error")
}
.getOrDefault(false),
)
}
return result
}
fun syncSharedState(state: SharedState) {
sharedState = state
applySharedState()
}
private suspend fun loadPreferencesAndStart() {
sharedState = host.loadSharedState()
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
host.showToast(MISSING_CONFIG_MESSAGE)
return
}
if (setupCore()) {
if (!requestStart().await()) {
host.showToast(START_FAILED_MESSAGE)
}
}
}
private fun applySharedState() {
host.setCrashlytics(sharedState.crashlytics)
host.updateNotificationParams(notificationParams(sharedState))
}
private suspend fun setupCore(): Boolean {
applySharedState()
host.showToast(sharedState.startTip)
return host.quickSetup(
initParams(host.homeDirPath, host.sdkInt),
Gson().toJson(sharedState.setupParams),
).fold(
onSuccess = { message ->
if (message.isEmpty()) {
true
} else {
host.log("Unable to set up core: $message")
showConfigError(message)
false
}
},
onFailure = { error ->
host.log("Unable to set up core: $error")
showConfigError(error.message)
false
},
)
}
private fun showConfigError(message: String?) {
host.showToast(message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE)
}
private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock {
val started = runStart(request)
if (!started) {
reconcileStopped()
}
started
}
private suspend fun runStart(request: RunRequest): Boolean {
if (!isCurrent(request)) {
return false
}
val options = sharedState.vpnOptions
if (options == null) {
fail(request)
return false
}
if (!prepareVpn(options)) {
if (host.app() == null && isCurrent(request)) {
host.showToast(VPN_PERMISSION_MESSAGE)
}
fail(request)
return false
}
if (!isCurrent(request)) {
return false
}
return transitionLock.withLock transition@{
if (!isCurrent(request)) {
return@transition false
}
if (runTimeMillis != 0L && host.isVpnServiceActive() == options.enable) {
mutableRunState.value = RunState.STARTED
return@transition true
}
mutableRunState.value = RunState.STARTING
val startedAtMillis = host.startService(withLocalNetworkFallback(options))
if (startedAtMillis == 0L) {
mutableRunState.value = RunState.STOPPED
fail(request)
return@transition false
}
if (!isCurrent(request)) {
return@transition false
}
mutableRunState.value = RunState.STARTED
true
}
}
// system/mixed hand TCP to the kernel via the tun subnet, which Android 17 gates as local network.
private fun withLocalNetworkFallback(options: VpnOptions): VpnOptions {
if (!options.enable || options.stack !in KERNEL_TCP_STACKS ||
host.isLocalNetworkPermissionGranted()
) {
return options
}
host.showToast(sharedState.localNetworkTip)
return options.copy(stack = FALLBACK_STACK)
}
private suspend fun reconcileStopped() = transitionLock.withLock {
if (isRunningRequested() || runTimeMillis == 0L) {
return@withLock
}
mutableRunState.value = RunState.STOPPING
host.stopService()
mutableRunState.value = RunState.STOPPED
}
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
if (!isCurrent(request)) {
return@withLock false
}
abandonVpnPreparation()
if (runState.value == RunState.STOPPED && runTimeMillis == 0L) {
return@withLock true
}
mutableRunState.value = RunState.STOPPING
host.stopService()
mutableRunState.value = RunState.STOPPED
isCurrent(request)
}
private suspend fun prepareVpn(options: VpnOptions): Boolean {
val app = host.app()
?: return !options.enable || host.isVpnPermissionGranted()
return suspendCancellableCoroutine { continuation ->
val callback: (Boolean) -> Unit = { granted ->
pendingVpnPreparation = null
if (continuation.isActive) {
continuation.resume(granted)
}
}
pendingVpnPreparation = {
app.cancelVpnPreparation(callback)
callback(false)
}
continuation.invokeOnCancellation {
pendingVpnPreparation = null
app.cancelVpnPreparation(callback)
}
app.prepareVpn(options.enable, callback)
}
}
private fun abandonVpnPreparation() {
val abandon = pendingVpnPreparation ?: return
pendingVpnPreparation = null
abandon()
}
private fun createRequest(running: Boolean): RunRequest = arbiter.request(running)
private fun isRunningRequested(): Boolean = arbiter.isRunningRequested
private fun isCurrent(request: RunRequest): Boolean = arbiter.isCurrent(request)
private fun fail(request: RunRequest) {
arbiter.resetToStopped(request)
}
internal companion object {
val KERNEL_TCP_STACKS = setOf("system", "mixed")
const val FALLBACK_STACK = "gvisor"
/**
* The Core init payload. The key spelling is a cross-language contract with the Go wrapper,
* not an implementation detail.
*/
fun initParams(homeDirPath: String, sdkInt: Int): String = Gson().toJson(
mapOf(
"home-dir" to homeDirPath,
"version" to sdkInt,
),
)
fun notificationParams(state: SharedState): NotificationParams = NotificationParams(
title = state.currentProfileName,
stopText = state.stopText,
onlyStatisticsProxy = state.onlyStatisticsProxy,
showStopAction = state.showStopAction,
)
}
}
@@ -26,6 +26,9 @@ class TileService : android.service.quicksettings.TileService() {
}
}
// Always route through QuickActionActivity: startActivityAndCollapse is
// what closes the shade, so toggling the service in place would leave
// the panel open after a tap.
override fun onClick() {
super.onClick()
openQuickAction()
@@ -6,10 +6,13 @@ import com.google.gson.annotations.SerializedName
data class SharedState(
val startTip: String = "Starting VPN...",
val stopTip: String = "Stopping VPN...",
val localNetworkTip: String =
"Local network permission denied: using the gvisor stack, LAN is unreachable.",
val crashlytics: Boolean = true,
val currentProfileName: String = "FlClash",
val stopText: String = "Stop",
val onlyStatisticsProxy: Boolean = false,
val showStopAction: Boolean = true,
val vpnOptions: VpnOptions? = null,
val setupParams: SetupParams? = null,
)
@@ -0,0 +1,84 @@
package com.follow.clash.packages
/**
* The prefixes in [CHINA_PACKAGE_REGEX] are matched *without* a trailing dot
* boundary, and that is deliberate: `com.qihoo` has to reach `com.qihoo360.*`
* and `com.ali` has to reach `com.aliyun.*` and `com.alimama.*`. Requiring a
* separator would turn those into misses.
*
* The price is that unrelated names starting with the same letters match too.
* [SKIPPED_PREFIXES] is where those come back out, and it *does* apply a dot
* boundary, because there the entries are whole package roots.
*/
internal object ChinaPackageMatcher {
fun isSkipped(packageName: String): Boolean = SKIPPED_PREFIXES.any {
packageName == it || packageName.startsWith("$it.")
}
fun matchesKnownPrefix(name: String): Boolean = name.matches(CHINA_PACKAGE_REGEX)
fun classNameOf(descriptor: String): String = descriptor
.removeSurrounding("L", ";")
.replace('/', '.')
.replace('$', '.')
private val SKIPPED_PREFIXES = listOf(
"com.google",
"com.android.chrome",
"com.android.vending",
"com.microsoft",
"com.apple",
"com.zhiliaoapp.musically",
// Caught by the loose "com.mx" prefix, which targets Maxthon's
// com.mx.browser. MX Player is unrelated.
"com.mxtech",
// Caught by the loose "com.stub" prefix, which targets the
// com.stub.StubApp packer. StubHub is unrelated.
"com.stubhub",
)
private val CHINA_PACKAGE_REGEX = listOf(
"com.tencent",
"com.alibaba",
"com.umeng",
"com.qihoo",
"com.ali",
"com.alipay",
"com.amap",
"com.sina",
"com.weibo",
"com.vivo",
"com.xiaomi",
"com.huawei",
"com.taobao",
"com.secneo",
"s.h.e.l.l",
"com.stub",
"com.kiwisec",
"com.secshell",
"com.wrapper",
"cn.securitystack",
"com.mogosec",
"com.secoen",
"com.netease",
"com.mx",
"com.qq.e",
"com.baidu",
"com.bytedance",
"com.bugly",
"com.miui",
"com.oppo",
"com.coloros",
"com.iqoo",
"com.meizu",
"com.gionee",
"cn.nubia",
"com.oplus",
"andes.oplus",
"com.unionpay",
"cn.wps",
).joinToString("|", prefix = "(", postfix = ").*") { prefix ->
Regex.escape(prefix)
}.toRegex()
}
@@ -14,8 +14,26 @@ internal class PackageResolver(
private val packageManager: PackageManager,
private val appPackageName: String,
) {
val installedPackages: List<InstalledPackage> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
loadPackages()
private val cacheLock = Any()
@Volatile
private var cachedPackages: List<InstalledPackage>? = null
val installedPackages: List<InstalledPackage>
get() = cachedPackages ?: synchronized(cacheLock) {
cachedPackages ?: loadPackages().also { cachedPackages = it }
}
val isInstalledAppsPermissionSupported: Boolean by lazy {
runCatching { packageManager.getPermissionInfo(GET_INSTALLED_APPS, 0) }.isSuccess
}
fun hasInstalledAppsPermission(): Boolean = !isInstalledAppsPermissionSupported ||
packageManager.checkPermission(GET_INSTALLED_APPS, appPackageName) ==
PackageManager.PERMISSION_GRANTED
fun invalidate() {
synchronized(cacheLock) { cachedPackages = null }
}
fun getChinaPackageNames(): List<String> = installedPackages
@@ -52,16 +70,16 @@ internal class PackageResolver(
}
private fun isChinaPackage(packageName: String): Boolean {
if (SKIPPED_PREFIXES.any { packageName == it || packageName.startsWith("$it.") }) {
if (ChinaPackageMatcher.isSkipped(packageName)) {
return false
}
if (packageName.matches(CHINA_PACKAGE_REGEX)) {
if (ChinaPackageMatcher.matchesKnownPrefix(packageName)) {
return true
}
return runCatching {
val packageInfo = getPackageInfo(packageName)
packageInfo.componentNames().any { it.matches(CHINA_PACKAGE_REGEX) } ||
packageInfo.componentNames().any(ChinaPackageMatcher::matchesKnownPrefix) ||
packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true
}.getOrDefault(false)
}
@@ -100,75 +118,22 @@ internal class PackageResolver(
DexBackedDexFile.fromInputStream(null, input)
}
dexFile.classes.any { clazz ->
clazz.type
.removeSurrounding("L", ";")
.replace('/', '.')
.replace('$', '.')
.matches(CHINA_PACKAGE_REGEX)
ChinaPackageMatcher.matchesKnownPrefix(
ChinaPackageMatcher.classNameOf(clazz.type),
)
}
}
}
private companion object {
const val ANDROID_PACKAGE_NAME = "android"
const val MAX_DEX_SIZE_BYTES = 15_000_000L
companion object {
const val GET_INSTALLED_APPS = "com.android.permission.GET_INSTALLED_APPS"
val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
private const val ANDROID_PACKAGE_NAME = "android"
private const val MAX_DEX_SIZE_BYTES = 15_000_000L
private val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
PackageManager.GET_SERVICES or
PackageManager.GET_RECEIVERS or
PackageManager.GET_PROVIDERS
val SKIPPED_PREFIXES = listOf(
"com.google",
"com.android.chrome",
"com.android.vending",
"com.microsoft",
"com.apple",
"com.zhiliaoapp.musically",
)
val CHINA_PACKAGE_REGEX = listOf(
"com.tencent",
"com.alibaba",
"com.umeng",
"com.qihoo",
"com.ali",
"com.alipay",
"com.amap",
"com.sina",
"com.weibo",
"com.vivo",
"com.xiaomi",
"com.huawei",
"com.taobao",
"com.secneo",
"s.h.e.l.l",
"com.stub",
"com.kiwisec",
"com.secshell",
"com.wrapper",
"cn.securitystack",
"com.mogosec",
"com.secoen",
"com.netease",
"com.mx",
"com.qq.e",
"com.baidu",
"com.bytedance",
"com.bugly",
"com.miui",
"com.oppo",
"com.coloros",
"com.iqoo",
"com.meizu",
"com.gionee",
"cn.nubia",
"com.oplus",
"andes.oplus",
"com.unionpay",
"cn.wps",
).joinToString("|", prefix = "(", postfix = ").*") { prefix ->
Regex.escape(prefix)
}.toRegex()
}
}
@@ -4,10 +4,15 @@ import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.ActivityManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.Settings
import androidx.core.app.ActivityCompat
@@ -20,8 +25,10 @@ import androidx.core.net.toUri
import com.follow.clash.R
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
import com.follow.clash.common.PendingCallback
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.registerReceiverCompat
import com.follow.clash.getPackageIconPath
import com.follow.clash.packages.PackageResolver
import com.follow.clash.showToast
@@ -32,6 +39,7 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -42,16 +50,32 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private val activityResultListener =
PluginRegistry.ActivityResultListener(::onActivityResult)
private val permissionsResultListener =
PluginRegistry.RequestPermissionsResultListener(::onRequestPermissionsResultListener)
private lateinit var channel: MethodChannel
private lateinit var scope: CoroutineScope
private var vpnPrepareCallback: ((Boolean) -> Unit)? = null
private val vpnPrepareCallback = PendingCallback<Boolean>()
private var requestNotificationCallback: ((Boolean) -> Unit)? = null
private val requestNotificationCallback = PendingCallback<Boolean>()
private val requestInstalledAppsCallback = PendingCallback<Boolean>()
private val requestLocalNetworkCallback = PendingCallback<Boolean>()
private var isRequestingNotificationPermission = false
private var isRequestingLocalNetworkPermission = false
private var skipLocalNetworkPermissionRequest = false
private val gson = Gson()
private val packageResolver by lazy {
@@ -61,9 +85,47 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
)
}
private var packageChangeContext: Context? = null
private val packageChangeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent == null || intent.data?.schemeSpecificPart == GlobalState.application.packageName) {
return
}
val addedByUpdate = intent.action == Intent.ACTION_PACKAGE_ADDED &&
intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
if (addedByUpdate) {
return
}
packageResolver.invalidate()
channel.invokeMethod("packagesChanged", null)
}
}
private var skipNotificationPermissionRequest = false
override fun onMethodCall(call: MethodCall, result: Result) {
private val mainHandler = Handler(Looper.getMainLooper())
/**
* The permission and consent hops below touch the Activity — starting an
* activity for result, raising a permission prompt — and read the state that
* tracks whether one is already up. Their callers are coroutines on
* [Dispatchers.Default], while the answers come back on the main thread
* through the ActivityAware listeners, so main is the one thread both ends
* can agree on. A plain main-looper post rather than the plugin scope: the
* scope is cancelled when the engine detaches, and a request dropped there
* would leave its caller waiting for a callback that can no longer run.
*/
private fun onMainThread(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
mainHandler.post(block)
}
}
override fun onMethodCall(call: MethodCall, rawResult: Result) {
val result = MainThreadResult(rawResult)
when (call.method) {
"moveTaskToBack" -> {
activity?.moveTaskToBack(true)
@@ -86,16 +148,20 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
}
"getPackages" -> {
scope.launch(Dispatchers.IO) {
result.success(gson.toJson(packageResolver.installedPackages))
}
"getPackages" -> reply(result) {
gson.toJson(packageResolver.installedPackages)
}
"getChinaPackageNames" -> {
scope.launch(Dispatchers.IO) {
result.success(gson.toJson(packageResolver.getChinaPackageNames()))
}
"getChinaPackageNames" -> reply(result) {
gson.toJson(packageResolver.getChinaPackageNames())
}
"isInstalledAppsPermissionGranted" -> reply(result) {
packageResolver.hasInstalledAppsPermission()
}
"requestInstalledAppsPermission" -> {
requestInstalledAppsPermission { granted -> result.success(granted) }
}
"getPackageIcon" -> {
@@ -120,8 +186,12 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
result.success(openAppSettings())
}
"didCrashOnPreviousExecution" -> {
result.success(GlobalState.didCrashOnPreviousExecution())
"didCrashOnPreviousExecution" -> reply(result) {
GlobalState.didCrashOnPreviousExecution()
}
"getLastExitInfo" -> reply(result) {
GlobalState.lastExitInfo()
}
else -> {
@@ -130,15 +200,21 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
}
private fun handleGetPackageIcon(call: MethodCall, result: Result) {
scope.launch {
val packageName = call.argument<String>("packageName")
if (packageName == null) {
result.success("")
return@launch
private fun handleGetPackageIcon(call: MethodCall, result: Result) = reply(result) {
val packageName = call.argument<String>("packageName") ?: ""
GlobalState.application.packageManager.getPackageIconPath(packageName)
}
// A throw inside the plugin scope has no handler and ends the process;
// the Dart caller gets a PlatformException to handle instead.
private fun reply(result: Result, block: suspend () -> Any?) {
scope.launch(Dispatchers.IO) {
try {
result.success(block())
} catch (error: Exception) {
GlobalState.log("Platform call failed: $error")
result.error("PLATFORM_ERROR", error.toString(), null)
}
val path = GlobalState.application.packageManager.getPackageIconPath(packageName)
result.success(path)
}
}
@@ -196,20 +272,20 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
@Suppress("DEPRECATION")
private fun updateExcludeFromRecents(value: Boolean?) {
val taskId = activity?.taskId ?: return
val am = getSystemService(GlobalState.application, ActivityManager::class.java)
val task = am?.appTasks?.firstOrNull {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
it.taskInfo.taskId == activity?.taskId
it.taskInfo?.taskId == taskId
} else {
it.taskInfo.id == activity?.taskId
it.taskInfo?.id == taskId
}
}
task?.setExcludeFromRecents(value ?: false)
}
fun requestNotificationPermission(callback: (Boolean) -> Unit) {
requestNotificationCallback?.invoke(false)
requestNotificationCallback = callback
fun requestNotificationPermission(callback: (Boolean) -> Unit) = onMainThread {
requestNotificationCallback.replace(callback, supersededValue = false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = ContextCompat.checkSelfPermission(
GlobalState.application,
@@ -217,10 +293,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
)
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
invokeRequestNotificationCallback(true)
return
return@onMainThread
}
if (isRequestingNotificationPermission) {
return
return@onMainThread
}
isRequestingNotificationPermission = true
activity?.let {
@@ -230,23 +306,82 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
NOTIFICATION_PERMISSION_REQUEST_CODE,
)
} ?: invokeRequestNotificationCallback(true)
return
return@onMainThread
}
invokeRequestNotificationCallback(true)
}
private fun invokeRequestNotificationCallback(shouldStart: Boolean) {
isRequestingNotificationPermission = false
requestNotificationCallback?.invoke(shouldStart)
requestNotificationCallback = null
requestNotificationCallback.resolve(shouldStart)
}
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) {
invokeVpnPrepareCallback(false)
vpnPrepareCallback = callback
fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) = onMainThread {
requestLocalNetworkCallback.replace(callback, supersededValue = false)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN || hasLocalNetworkPermission()) {
invokeRequestLocalNetworkCallback(true)
return@onMainThread
}
if (skipLocalNetworkPermissionRequest) {
invokeRequestLocalNetworkCallback(false)
return@onMainThread
}
if (isRequestingLocalNetworkPermission) {
return@onMainThread
}
val activity = activity
if (activity == null) {
invokeRequestLocalNetworkCallback(false)
return@onMainThread
}
isRequestingLocalNetworkPermission = true
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.ACCESS_LOCAL_NETWORK),
LOCAL_NETWORK_PERMISSION_REQUEST_CODE,
)
}
private fun hasLocalNetworkPermission(): Boolean = ContextCompat.checkSelfPermission(
GlobalState.application,
Manifest.permission.ACCESS_LOCAL_NETWORK,
) == PackageManager.PERMISSION_GRANTED
private fun invokeRequestLocalNetworkCallback(granted: Boolean) {
isRequestingLocalNetworkPermission = false
requestLocalNetworkCallback.resolve(granted)
}
private fun requestInstalledAppsPermission(callback: (Boolean) -> Unit) = onMainThread {
requestInstalledAppsCallback.replace(callback, supersededValue = false)
val activity = activity
if (packageResolver.hasInstalledAppsPermission()) {
invokeRequestInstalledAppsCallback(true)
return@onMainThread
}
if (activity == null) {
invokeRequestInstalledAppsCallback(false)
return@onMainThread
}
ActivityCompat.requestPermissions(
activity,
arrayOf(PackageResolver.GET_INSTALLED_APPS),
INSTALLED_APPS_PERMISSION_REQUEST_CODE,
)
}
private fun invokeRequestInstalledAppsCallback(granted: Boolean) {
if (granted) {
packageResolver.invalidate()
}
requestInstalledAppsCallback.resolve(granted)
}
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) = onMainThread {
vpnPrepareCallback.replace(callback, supersededValue = false)
if (!needPrepare) {
invokeVpnPrepareCallback(true)
return
return@onMainThread
}
val intent = VpnService.prepare(GlobalState.application)
if (intent != null) {
@@ -257,20 +392,19 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
@Suppress("DEPRECATION")
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
}
return
return@onMainThread
}
invokeVpnPrepareCallback(true)
}
fun cancelVpnPreparation(callback: (Boolean) -> Unit) {
if (vpnPrepareCallback === callback) {
vpnPrepareCallback = null
}
// Posted rather than run where the cancellation lands, so it stays ordered
// behind the prepareVpn that installed the callback it is cancelling.
fun cancelVpnPreparation(callback: (Boolean) -> Unit) = onMainThread {
vpnPrepareCallback.cancel(callback)
}
private fun invokeVpnPrepareCallback(granted: Boolean) {
vpnPrepareCallback?.invoke(granted)
vpnPrepareCallback = null
vpnPrepareCallback.resolve(granted)
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
@@ -278,13 +412,29 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
channel =
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
channel.setMethodCallHandler(this)
watchPackageChanges(flutterPluginBinding.applicationContext)
}
private fun watchPackageChanges(context: Context) {
val filter = IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
addDataScheme("package")
}
context.registerReceiverCompat(packageChangeReceiver, filter)
packageChangeContext = context
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
packageChangeContext?.unregisterReceiver(packageChangeReceiver)
packageChangeContext = null
channel.setMethodCallHandler(null)
scope.cancel()
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
invokeRequestLocalNetworkCallback(false)
invokeRequestInstalledAppsCallback(false)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
@@ -292,13 +442,23 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
private fun attachToActivity(binding: ActivityPluginBinding) {
detachFromActivity()
activityBinding = binding
activity = binding.activity
binding.addActivityResultListener(::onActivityResult)
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
binding.addActivityResultListener(activityResultListener)
binding.addRequestPermissionsResultListener(permissionsResultListener)
}
private fun detachFromActivity() {
activity = null
val binding = activityBinding ?: return
activityBinding = null
binding.removeActivityResultListener(activityResultListener)
binding.removeRequestPermissionsResultListener(permissionsResultListener)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
detachFromActivity()
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
@@ -307,9 +467,11 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
override fun onDetachedFromActivity() {
channel.invokeMethod("exit", null)
activity = null
detachFromActivity()
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
invokeRequestLocalNetworkCallback(false)
invokeRequestInstalledAppsCallback(false)
}
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
@@ -324,17 +486,37 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
): Boolean {
if (requestCode != NOTIFICATION_PERMISSION_REQUEST_CODE) {
return false
): Boolean = when (requestCode) {
NOTIFICATION_PERMISSION_REQUEST_CODE -> {
skipNotificationPermissionRequest = true
invokeRequestNotificationCallback(true)
true
}
skipNotificationPermissionRequest = true
invokeRequestNotificationCallback(true)
return true
INSTALLED_APPS_PERMISSION_REQUEST_CODE -> {
invokeRequestInstalledAppsCallback(
grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED,
)
true
}
LOCAL_NETWORK_PERMISSION_REQUEST_CODE -> {
skipLocalNetworkPermissionRequest = true
invokeRequestLocalNetworkCallback(
grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED,
)
true
}
else -> false
}
private companion object {
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
const val INSTALLED_APPS_PERMISSION_REQUEST_CODE = 1003
const val LOCAL_NETWORK_PERMISSION_REQUEST_CODE = 1004
}
}
@@ -0,0 +1,37 @@
package com.follow.clash.plugins
import android.os.Handler
import android.os.Looper
import io.flutter.plugin.common.MethodChannel
/**
* Delivers a [MethodChannel.Result] on the main thread, which is where Flutter
* requires it, from handlers that finish on a background dispatcher.
*
* The hop goes through the main looper rather than the plugin's coroutine scope:
* that scope is cancelled when the engine detaches, and a reply dropped by a
* cancelled scope leaves the Dart future waiting forever. A reply posted after
* the engine is gone is the messenger's problem to ignore, and it does.
*/
internal class MainThreadResult(
private val delegate: MethodChannel.Result,
) : MethodChannel.Result {
override fun success(result: Any?) = post { delegate.success(result) }
override fun error(code: String, message: String?, details: Any?) =
post { delegate.error(code, message, details) }
override fun notImplemented() = post { delegate.notImplemented() }
private fun post(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
mainHandler.post(block)
}
}
private companion object {
val mainHandler = Handler(Looper.getMainLooper())
}
}
@@ -31,7 +31,11 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
ServiceController.setEventListener(null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
override fun onMethodCall(call: MethodCall, rawResult: MethodChannel.Result) {
// Most handlers below reply from a scope worker on Dispatchers.Default,
// but a MethodChannel.Result has to be answered on the platform thread.
// Wrapping once here covers every branch, including notImplemented.
val result = MainThreadResult(rawResult)
when (call.method) {
"init" -> initialize(result)
"shutdown" -> shutdown(result)
@@ -63,12 +67,13 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
result.error("INVALID_ARGUMENT", "Method call payload must be a string", null)
return
}
scope.launch {
ServiceController.invokeMethod(data) { response ->
result.success(response)
}.onFailure { error ->
result.error("CORE_ERROR", error.message, null)
}
// Inline rather than a coroutine per call, which could swap two: watchRoute
// keeps the latest intent by the order calls reach the Core
// (dispatchMethodCall in core/method.go), and this only hands data over.
ServiceController.invokeMethod(data) { response ->
result.success(response)
}.onFailure { error ->
result.error("CORE_ERROR", error.message, null)
}
}
+3
View File
@@ -14,6 +14,9 @@ rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
+6
View File
@@ -16,6 +16,11 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
sourceSets {
// Unit tests live under android/tests/ instead of each module's src/test.
getByName("test").java.setSrcDirs(listOf("../tests/common"))
}
}
kotlin {
@@ -30,4 +35,5 @@ dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics.ndk)
implementation(libs.firebase.analytics)
testImplementation(libs.junit)
}
@@ -0,0 +1,32 @@
package com.follow.clash.common
import java.util.concurrent.atomic.AtomicBoolean
/**
* A `BroadcastReceiver.goAsync()` path holds the broadcast open until its
* `PendingResult` is finished, and finishing twice throws. Normal completion and
* the timeout watchdog both race to release it, so the winner is decided here
* rather than by whichever callback happens to run first.
*
* Releasing the lease says only that Android may stop waiting for this receiver.
* It does not cancel, reverse, or otherwise redefine the work the broadcast
* started, which keeps running under its own owner.
*/
class BroadcastLease(private val release: () -> Unit) {
private val released = AtomicBoolean(false)
val isReleased: Boolean
get() = released.get()
fun release(onRelease: () -> Unit = {}): Boolean {
if (!released.compareAndSet(false, true)) {
return false
}
try {
onRelease()
} finally {
release.invoke()
}
return true
}
}
@@ -59,19 +59,24 @@ val Intent.toPendingIntent: PendingIntent
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
fun Service.startForeground(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
var channel = manager?.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL)
if (channel == null) {
channel = NotificationChannel(
GlobalState.NOTIFICATION_CHANNEL,
getString(R.string.service_channel_name),
NotificationManager.IMPORTANCE_LOW,
)
manager?.createNotificationChannel(channel)
}
fun Context.ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL) != null) {
return
}
manager.createNotificationChannel(
NotificationChannel(
GlobalState.NOTIFICATION_CHANNEL,
getString(R.string.service_channel_name),
NotificationManager.IMPORTANCE_LOW,
),
)
}
fun Service.startForeground(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
GlobalState.NOTIFICATION_ID,
@@ -1,6 +1,8 @@
package com.follow.clash.common
import android.app.ActivityManager
import android.app.Application
import android.os.Build
import android.util.Log
import com.google.firebase.FirebaseApp
import com.google.firebase.crashlytics.FirebaseCrashlytics
@@ -11,6 +13,8 @@ import kotlinx.coroutines.SupervisorJob
object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
const val NOTIFICATION_CHANNEL = "FlClash"
const val NOTIFICATION_ID = 1
private const val ANY_PID = 0
private const val EVERY_EXIT_RECORD = 0
val packageName: String
get() = application.packageName
@@ -44,4 +48,21 @@ object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatch
FirebaseApp.initializeApp(application)
return FirebaseCrashlytics.getInstance().didCrashOnPreviousExecution()
}
fun lastExitInfo(): Map<String, Any?>? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
val manager = application.getSystemService(ActivityManager::class.java) ?: return null
val info = runCatching {
manager.getHistoricalProcessExitReasons(
application.packageName,
ANY_PID,
EVERY_EXIT_RECORD,
)
}.getOrNull()?.firstOrNull { it.processName == application.packageName } ?: return null
return mapOf(
"reason" to info.reason,
"timestamp" to info.timestamp,
"description" to info.description,
)
}
}
@@ -0,0 +1,53 @@
package com.follow.clash.common
/**
* Only the newest request is ever pending: [replace] settles the request it
* supersedes rather than dropping it, so no caller is left waiting forever. The
* slot is cleared before the callback runs, so a callback that starts another
* request cannot resolve itself twice.
*
* The two ends of such a hop live on different threads — the request is started
* from whatever coroutine wants the permission, the answer arrives on the main
* thread through `onActivityResult` — so every read and write of the slot is a
* single atomic swap. Without that, a resolve that has already read the slot can
* clear a callback a concurrent replace installed after it, and the request that
* callback belonged to never completes: the VPN consent answer is lost and the
* lock the start path holds is never released.
*
* Callbacks run outside the lock. They resume coroutines and can start the next
* request, and holding the monitor across that would make the slot's lock part
* of every caller's lock order.
*/
class PendingCallback<T> {
private val lock = Any()
private var callback: ((T) -> Unit)? = null
val isPending: Boolean
get() = synchronized(lock) { callback != null }
fun replace(next: (T) -> Unit, supersededValue: T) {
val superseded = synchronized(lock) {
val current = callback
callback = next
current
}
superseded?.invoke(supersededValue)
}
fun resolve(value: T) {
val current = synchronized(lock) {
val current = callback ?: return
callback = null
current
}
current(value)
}
fun cancel(target: (T) -> Unit) {
synchronized(lock) {
if (callback === target) {
callback = null
}
}
}
}
@@ -0,0 +1,28 @@
package com.follow.clash.common
import java.util.concurrent.atomic.AtomicReference
/**
* Every request mints a fresh [Token]. Work that was started for an older token is obsolete once a
* newer request arrives, so callers check [isCurrent] before applying a result and roll back with
* [resetToStopped], which only wins while the token is still the latest one.
*/
class RunIntentArbiter(initialRunning: Boolean = false) {
class Token internal constructor(
val running: Boolean,
)
private val latest = AtomicReference(Token(initialRunning))
val isRunningRequested: Boolean
get() = latest.get().running
fun current(): Token = latest.get()
fun request(running: Boolean): Token = Token(running).also(latest::set)
fun isCurrent(token: Token): Boolean = latest.get() === token
fun resetToStopped(token: Token): Boolean =
latest.compareAndSet(token, Token(running = false))
}
+56
View File
@@ -4,6 +4,15 @@ plugins {
id("com.android.library")
}
// Must equal Target.forPlatform("android") in setup_hooks, which writes libclash.so only for the platforms being built.
val coreAbiByPlatform =
linkedMapOf("android-arm" to "armeabi-v7a", "android-arm64" to "arm64-v8a", "android-x64" to "x86_64")
val coreAbis =
(rootProject.findProperty("target-platform") as String?)
?.split(",")
?.map { coreAbiByPlatform[it.trim()] ?: throw GradleException("No Core for Flutter target platform $it") }
?: coreAbiByPlatform.values.toList()
android {
namespace = "com.follow.clash.core"
compileSdk = libs.versions.compileSdk.get().toInt()
@@ -11,6 +20,10 @@ android {
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
ndk {
abiFilters += coreAbis
}
}
externalNativeBuild {
@@ -20,6 +33,13 @@ android {
}
}
// :app strips what it packages; Crashlytics symbolication needs the unstripped libraries to reach its merge.
packaging {
jniLibs {
keepDebugSymbols += "**/*.so"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -36,3 +56,39 @@ kotlin {
dependencies {
implementation(libs.annotation.jvm)
}
// Why prefix matching and mustRunAfter: .agents/architecture.md, "Android Native Task Ordering".
val nativeTaskPattern =
Regex("^(configureCMake|buildCMake|externalNativeBuild|merge.*(NativeLibs|JniLibFolders)|copy.*JniLibs)")
val flutterCompileTasks =
rootProject.project(":app").tasks.matching { it.name.startsWith("compileFlutterBuild") }
tasks.matching { nativeTaskPattern.containsMatchIn(it.name) }.configureEach {
mustRunAfter(flutterCompileTasks)
}
gradle.taskGraph.whenReady {
val nativeTask =
allTasks.firstOrNull {
it.path.startsWith("${project.path}:") && nativeTaskPattern.containsMatchIn(it.name)
}
if (nativeTask == null || allTasks.any { it.path.startsWith(":app:compileFlutterBuild") }) {
return@whenReady
}
val missing =
coreAbis
.flatMap {
listOf(file("src/main/jniLibs/$it/libclash.so"), file("src/main/cpp/includes/$it/libclash.h"))
}.filterNot { it.isFile }
if (missing.isNotEmpty()) {
throw GradleException(
"${nativeTask.name} consumes Core artifacts that the setup build hook writes into src/main, " +
"and these are absent: ${missing.joinToString { it.relativeTo(projectDir).path }}. " +
"Run `dart setup.dart android` once, or build through :app so the hook runs first.",
)
}
logger.warn(
":core: ${nativeTask.name} is running without an :app Flutter compile task, " +
"so the Core artifacts under src/main are whatever the last hook run left behind",
)
}
+13 -23
View File
@@ -9,30 +9,20 @@ message("CMAKE_BUILD_TYPE ${CMAKE_BUILD_TYPE}")
if (NOT "${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
add_compile_options(-O3 -flto -g0 -fno-exceptions -fno-rtti)
add_link_options(-flto -Wl,--gc-sections,--strip-all)
add_compile_options(-O3 -flto -g -fno-exceptions -fno-rtti)
add_link_options(-flto -Wl,--gc-sections)
endif ()
set(LIB_CLASH_PATH "${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libclash.so")
set(LIB_CLASH_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/../cpp/includes/${ANDROID_ABI}")
set(LIB_CLASH_HEADER_PATH "${LIB_CLASH_INCLUDE_DIR}/libclash.h")
set(BRIDE_HEADER_PATH "${LIB_CLASH_INCLUDE_DIR}/bride.h")
message("LIB_CLASH_PATH ${LIB_CLASH_PATH}")
if (EXISTS ${LIB_CLASH_PATH} AND EXISTS ${LIB_CLASH_HEADER_PATH} AND EXISTS ${BRIDE_HEADER_PATH})
message("Found libclash.so and headers for ABI ${ANDROID_ABI}")
add_compile_definitions(LIBCLASH)
include_directories(${LIB_CLASH_INCLUDE_DIR})
link_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
add_library(${CMAKE_PROJECT_NAME} SHARED
jni_helper.cpp
core.cpp)
target_link_libraries(${CMAKE_PROJECT_NAME}
clash)
else ()
message("Not found complete libclash output for ABI ${ANDROID_ABI}; building core without LIBCLASH")
add_library(${CMAKE_PROJECT_NAME} SHARED
jni_helper.cpp
core.cpp)
target_link_libraries(${CMAKE_PROJECT_NAME})
endif ()
# Never make linking libclash conditional on the file existing: AGP's configure
# fingerprint tracks only CMakeLists.txt, so a configure that ran before the build
# hook would stay cached and silently drop the Core. Failing at compile or link
# time is what keeps that decision out of the configure step.
include_directories(${LIB_CLASH_INCLUDE_DIR})
link_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
add_library(${CMAKE_PROJECT_NAME} SHARED
jni_helper.cpp
core.cpp)
target_link_libraries(${CMAKE_PROJECT_NAME}
clash)
+64 -88
View File
@@ -1,17 +1,19 @@
#include <jni.h>
#ifdef LIBCLASH
#include <cstring>
#include "jni_helper.h"
#include "libclash.h"
#include "bride.h"
extern "C"
JNIEXPORT void JNICALL
JNIEXPORT jboolean JNICALL
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
jstring stack, jstring address, jstring dns) {
const auto interface = new_global(cb);
startTUN(interface, fd, get_string(stack), get_string(address), get_string(dns));
return startTUN(interface, fd, get_string(stack), get_string(address), get_string(dns))
? JNI_TRUE
: JNI_FALSE;
}
extern "C"
@@ -66,12 +68,6 @@ Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
return new_string(traffic);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
suspend(suspended);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
@@ -82,7 +78,8 @@ Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring in
static jmethodID m_tun_interface_protect;
static jmethodID m_tun_interface_resolve_process;
static jmethodID m_tun_interface_resolve_uid;
static jmethodID m_tun_interface_resolve_package;
static jmethodID m_invoke_interface_result;
@@ -95,30 +92,56 @@ static void free_string_impl(char *str) {
free(str);
}
static void call_tun_interface_protect_impl(void *tun_interface, const int fd) {
static int call_tun_interface_protect_impl(void *tun_interface, const int fd) {
// ART aborts the process on a call through a null object, so a callback
// that was already released has to stop here rather than at the JNI call.
if (tun_interface == nullptr) {
return 0;
}
ATTACH_JNI();
env->CallVoidMethod(static_cast<jobject>(tun_interface),
m_tun_interface_protect,
fd);
const auto accepted = env->CallBooleanMethod(static_cast<jobject>(tun_interface),
m_tun_interface_protect,
fd);
if (jni_clear_exception(env)) {
return 0;
}
return accepted == JNI_TRUE ? 1 : 0;
}
static char *
call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
const char *source,
const char *target,
const int uid) {
static int call_tun_interface_resolve_uid_impl(void *tun_interface, const int protocol,
const char *source,
const char *target) {
if (tun_interface == nullptr) {
return -1;
}
ATTACH_JNI();
const auto source_string = new_string(source);
const auto target_string = new_string(target);
const auto uid = env->CallIntMethod(static_cast<jobject>(tun_interface),
m_tun_interface_resolve_uid,
protocol,
source_string,
target_string);
const auto failed = jni_clear_exception(env);
if (source_string != nullptr) {
env->DeleteLocalRef(source_string);
}
if (target_string != nullptr) {
env->DeleteLocalRef(target_string);
}
return failed ? -1 : uid;
}
static char *call_tun_interface_resolve_package_impl(void *tun_interface, const int uid) {
if (tun_interface == nullptr) {
return strdup("");
}
ATTACH_JNI();
const auto package_name = reinterpret_cast<jstring>(env->CallObjectMethod(
static_cast<jobject>(tun_interface),
m_tun_interface_resolve_process,
protocol,
source_string,
target_string,
m_tun_interface_resolve_package,
uid));
env->DeleteLocalRef(source_string);
env->DeleteLocalRef(target_string);
jni_clear_exception(env);
const auto result = get_string(package_name);
if (package_name != nullptr) {
env->DeleteLocalRef(package_name);
@@ -127,12 +150,18 @@ call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
}
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
if (invoke_interface == nullptr) {
return;
}
ATTACH_JNI();
const auto value = new_string(data);
const auto value = new_bytes(data);
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
m_invoke_interface_result,
value);
env->DeleteLocalRef(value);
jni_clear_exception(env);
if (value != nullptr) {
env->DeleteLocalRef(value);
}
}
extern "C"
@@ -149,74 +178,21 @@ JNI_OnLoad(JavaVM *vm, void *) {
const auto c_invoke_interface = find_class("com/follow/clash/core/InvokeInterface");
m_tun_interface_protect = find_method(c_tun_interface, "protect", "(I)V");
m_tun_interface_resolve_process = find_method(c_tun_interface, "resolverProcess",
"(ILjava/lang/String;Ljava/lang/String;I)Ljava/lang/String;");
m_tun_interface_protect = find_method(c_tun_interface, "protect", "(I)Z");
m_tun_interface_resolve_uid = find_method(c_tun_interface, "resolveUid",
"(ILjava/lang/String;Ljava/lang/String;)I");
m_tun_interface_resolve_package = find_method(c_tun_interface, "resolvePackage",
"(I)Ljava/lang/String;");
m_invoke_interface_result = find_method(c_invoke_interface, "onResult",
"(Ljava/lang/String;)V");
"([B)V");
protect_func = &call_tun_interface_protect_impl;
resolve_process_func = &call_tun_interface_resolve_process_impl;
resolve_uid_func = &call_tun_interface_resolve_uid_impl;
resolve_package_func = &call_tun_interface_resolve_package_impl;
result_func = &call_invoke_interface_result_impl;
release_object_func = &release_jni_object_impl;
free_string_func = &free_string_impl;
return JNI_VERSION_1_6;
}
#else
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
jstring stack, jstring address, jstring dns) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_forceGC(JNIEnv *env, jobject thiz) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_setEventListener(JNIEnv *env, jobject thiz, jobject cb) {
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return env->NewStringUTF("{}");
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return env->NewStringUTF("{}");
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
jstring setup_params_string, jobject cb) {
}
#endif
+74 -2
View File
@@ -2,6 +2,7 @@
#include <cstdlib>
#include <cstring>
#include <pthread.h>
static JavaVM *global_vm;
@@ -9,44 +10,115 @@ static jclass c_string;
static jmethodID m_new_string;
static jmethodID m_get_bytes;
static pthread_key_t detach_key;
static bool detach_key_ready;
static void detach_current_thread(void *) {
global_vm->DetachCurrentThread();
}
void initialize_jni(JavaVM *vm, JNIEnv *env) {
global_vm = vm;
detach_key_ready = pthread_key_create(&detach_key, detach_current_thread) == 0;
c_string = reinterpret_cast<jclass>(new_global(find_class("java/lang/String")));
m_new_string = find_method(c_string, "<init>", "([B)V");
m_get_bytes = find_method(c_string, "getBytes", "()[B");
}
bool jni_clear_exception(JNIEnv *env) {
if (env->ExceptionCheck() == JNI_FALSE) {
return false;
}
env->ExceptionDescribe();
env->ExceptionClear();
return true;
}
static char *empty_string() {
return static_cast<char *>(calloc(1, 1));
}
char *jni_get_string(JNIEnv *env, jstring str) {
if (str == nullptr) {
return static_cast<char *>(calloc(1, 1));
return empty_string();
}
const auto array = reinterpret_cast<jbyteArray>(env->CallObjectMethod(str, m_get_bytes));
if (jni_clear_exception(env) || array == nullptr) {
return empty_string();
}
const int length = env->GetArrayLength(array);
const auto content = static_cast<char *>(malloc(length + 1));
if (content == nullptr) {
env->DeleteLocalRef(array);
return empty_string();
}
env->GetByteArrayRegion(array, 0, length, reinterpret_cast<jbyte *>(content));
if (jni_clear_exception(env)) {
// The copy did not happen, so `content` still holds whatever malloc
// handed back. Returning it would pass that heap content on as the
// string the caller asked for.
free(content);
env->DeleteLocalRef(array);
return empty_string();
}
env->DeleteLocalRef(array);
content[length] = 0;
return content;
}
jstring jni_new_string(JNIEnv *env, const char *str) {
if (str == nullptr) {
str = "";
}
const auto length = static_cast<int>(strlen(str));
const auto array = env->NewByteArray(length);
if (jni_clear_exception(env) || array == nullptr) {
return env->NewStringUTF("");
}
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
if (jni_clear_exception(env)) {
// Calling NewObject with an exception still pending is undefined, and
// the array it would read from was not filled in anyway.
env->DeleteLocalRef(array);
return env->NewStringUTF("");
}
const auto result = reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
jni_clear_exception(env);
env->DeleteLocalRef(array);
if (result == nullptr) {
return env->NewStringUTF("");
}
return result;
}
jbyteArray jni_new_bytes(JNIEnv *env, const char *str) {
const auto length = str == nullptr ? 0 : static_cast<int>(strlen(str));
const auto array = env->NewByteArray(length);
if (jni_clear_exception(env) || array == nullptr) {
return nullptr;
}
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
if (jni_clear_exception(env)) {
env->DeleteLocalRef(array);
return nullptr;
}
return array;
}
void jni_attach_thread(scoped_jni *jni) {
if (global_vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
jni->require_release = 0;
return;
}
if (global_vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) {
if (global_vm->AttachCurrentThreadAsDaemon(&jni->env, nullptr) != JNI_OK) {
abort();
}
if (detach_key_ready && pthread_setspecific(detach_key, jni->env) == 0) {
jni->require_release = 0;
return;
}
jni->require_release = 1;
}
+5
View File
@@ -11,6 +11,8 @@ extern void initialize_jni(JavaVM *vm, JNIEnv *env);
extern jstring jni_new_string(JNIEnv *env, const char *str);
extern jbyteArray jni_new_bytes(JNIEnv *env, const char *str);
extern char *jni_get_string(JNIEnv *env, jstring str);
extern void jni_attach_thread(scoped_jni *jni);
@@ -19,6 +21,8 @@ extern void jni_detach_thread(const scoped_jni *env);
extern void release_string(char **value);
extern bool jni_clear_exception(JNIEnv *env);
#define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \
scoped_jni _jni{}; \
jni_attach_thread(&_jni); \
@@ -32,3 +36,4 @@ extern void release_string(char **value);
#define del_global(obj) env->DeleteGlobalRef(obj)
#define get_string(jstr) jni_get_string(env, jstr)
#define new_string(cstr) jni_new_string(env, cstr)
#define new_bytes(cstr) jni_new_bytes(env, cstr)
@@ -11,7 +11,7 @@ object Core {
stack: String,
address: String,
dns: String,
)
): Boolean
external fun forceGC()
@@ -29,31 +29,30 @@ object Core {
fun startTun(
fd: Int,
protect: (Int) -> Boolean,
resolverProcess: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress, uid: Int) -> String,
resolveUid: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress) -> Int,
resolvePackage: (uid: Int) -> String,
stack: String,
address: String,
dns: String,
) {
startTun(
): Boolean {
return startTun(
fd,
object : TunInterface {
override fun protect(fd: Int) {
protect(fd)
}
override fun protect(fd: Int): Boolean = protect(fd)
override fun resolverProcess(
override fun resolveUid(
protocol: Int,
source: String,
target: String,
uid: Int,
): String {
return resolverProcess(
): Int {
return resolveUid(
protocol,
parseInetSocketAddress(source),
parseInetSocketAddress(target),
uid,
)
}
override fun resolvePackage(uid: Int): String = resolvePackage(uid)
},
stack,
address,
@@ -61,10 +60,6 @@ object Core {
)
}
external fun suspended(
suspended: Boolean,
)
private external fun invokeMethod(
data: String,
cb: InvokeInterface,
@@ -72,12 +67,12 @@ object Core {
fun invokeMethod(
data: String,
cb: (result: String?) -> Unit,
cb: (result: ByteArray?) -> Unit,
) {
invokeMethod(
data,
object : InvokeInterface {
override fun onResult(result: String?) {
override fun onResult(result: ByteArray?) {
cb(result)
}
},
@@ -94,8 +89,8 @@ object Core {
} else {
setEventListener(
object : InvokeInterface {
override fun onResult(result: String?) {
callback(result)
override fun onResult(result: ByteArray?) {
callback(result?.decodeToString())
}
},
)
@@ -111,8 +106,8 @@ object Core {
initParamsString,
setupParamsString,
object : InvokeInterface {
override fun onResult(result: String?) {
callback(result)
override fun onResult(result: ByteArray?) {
callback(result?.decodeToString())
}
},
)
@@ -4,5 +4,5 @@ import androidx.annotation.Keep
@Keep
interface InvokeInterface {
fun onResult(result: String?)
fun onResult(result: ByteArray?)
}
@@ -4,7 +4,11 @@ import androidx.annotation.Keep
@Keep
interface TunInterface {
fun protect(fd: Int)
/** Returns false when the socket could not be kept out of the tunnel. */
fun protect(fd: Int): Boolean
fun resolverProcess(protocol: Int, source: String, target: String, uid: Int): String
/** Returns the uid owning the connection, or -1 when the system will not name it. */
fun resolveUid(protocol: Int, source: String, target: String): Int
fun resolvePackage(uid: Int): String
}
+13 -9
View File
@@ -1,14 +1,16 @@
[versions]
firebaseBom = "34.15.0"
minSdk = "23"
targetSdk = "36"
compileSdk = "36"
firebaseBom = "34.19.0"
minSdk = "24"
targetSdk = "37"
compileSdk = "37"
ndkVersion = "28.2.13676358"
coreKtx = "1.17.0"
annotationJvm = "1.9.1"
coreSplashscreen = "1.0.1"
gson = "2.13.1"
smaliDexlib2 = "3.0.9"
coreKtx = "1.19.1"
annotationJvm = "1.11.0"
coreSplashscreen = "1.2.0"
gson = "2.14.0"
smaliDexlib2 = "3.0.10"
junit = "4.13.2"
kotlinxCoroutines = "1.11.0"
[libraries]
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
@@ -18,4 +20,6 @@ firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
junit = { module = "junit:junit", version.ref = "junit" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
smali-dexlib2 = { module = "com.android.tools.smali:smali-dexlib2", version.ref = "smaliDexlib2" }
+1 -1
View File
@@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-all.zip
+6
View File
@@ -17,6 +17,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
sourceSets {
// Unit tests live under android/tests/ instead of each module's src/test.
getByName("test").java.setSrcDirs(listOf("../tests/service"))
}
}
kotlin {
@@ -30,4 +34,6 @@ dependencies {
implementation(project(":common"))
implementation(libs.gson)
implementation(libs.androidx.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -1,6 +1,7 @@
package com.follow.clash.service
import android.content.Intent
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.ProxyInfo
import android.os.Binder
@@ -41,26 +42,26 @@ class VpnService : SystemVpnService(), ManagedService {
}
private val uidPackageNameMap = ConcurrentHashMap<Int, String>()
private fun resolverProcess(
private fun resolveUid(
protocol: Int,
source: InetSocketAddress,
target: InetSocketAddress,
uid: Int,
): String {
val nextUid = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
connectivity?.getConnectionOwnerUid(protocol, source, target) ?: -1
} else {
uid
}
if (nextUid == -1) {
return ""
}
return uidPackageNameMap.getOrPut(nextUid) {
packageManager
.getPackagesForUid(nextUid)
?.firstOrNull()
.orEmpty()
): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return -1
}
return connectivity?.getConnectionOwnerUid(protocol, source, target) ?: -1
}
private fun resolvePackage(uid: Int): String {
val cached = uidPackageNameMap[uid]
if (cached != null) return cached
val packageName = packageManager
.getPackagesForUid(uid)
?.firstOrNull()
?.takeIf { it.isNotEmpty() }
.orEmpty()
return uidPackageNameMap.putIfAbsent(uid, packageName) ?: packageName
}
private val VpnOptions.tunAddress
@@ -145,14 +146,22 @@ class VpnService : SystemVpnService(), ManagedService {
synchronized(tunLock) {
tunRunning = true
try {
Core.startTun(
fd = fd,
protect = this::protect,
resolverProcess = this::resolverProcess,
stack = options.stack,
address = options.tunAddress,
dns = options.tunDns,
)
// A Core that fails to take the descriptor leaves the system
// routes pointing at an interface nothing reads, and it no
// longer keeps its own sockets out of them: every connection
// then hangs until it times out. Tear the VPN down instead of
// reporting a start that only looks successful.
check(
Core.startTun(
fd = fd,
protect = this::protect,
resolveUid = this::resolveUid,
resolvePackage = this::resolvePackage,
stack = options.stack,
address = options.tunAddress,
dns = options.tunDns,
),
) { "Core rejected the tun file descriptor" }
} catch (error: Exception) {
stopTunLocked()
throw error
@@ -216,15 +225,28 @@ class VpnService : SystemVpnService(), ManagedService {
if (!accessControl.enable) return
when (accessControl.mode) {
AccessControlMode.ACCEPT_SELECTED -> {
(accessControl.acceptList + packageName).forEach(::addAllowedApplication)
(accessControl.acceptList + packageName).forEach { name ->
addApplication(name, ::addAllowedApplication)
}
}
AccessControlMode.REJECT_SELECTED -> {
(accessControl.rejectList - packageName).forEach(::addDisallowedApplication)
(accessControl.rejectList - packageName).forEach { name ->
addApplication(name, ::addDisallowedApplication)
}
}
}
}
// A selected package that was uninstalled since must not veto the whole tunnel.
private fun addApplication(name: String, add: (String) -> Builder) {
try {
add(name)
} catch (_: PackageManager.NameNotFoundException) {
GlobalState.log("Access control skipped an uninstalled package: $name")
}
}
override fun start() {
try {
modules.start()
@@ -4,4 +4,5 @@ data class NotificationParams(
val title: String = "FlClash",
val stopText: String = "STOP",
val onlyStatisticsProxy: Boolean = false,
val showStopAction: Boolean = true,
)
@@ -9,6 +9,8 @@ import android.net.NetworkCapabilities.TRANSPORT_SATELLITE
import android.net.NetworkCapabilities.TRANSPORT_USB
import android.net.NetworkRequest
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.content.getSystemService
import com.follow.clash.core.Core
import java.net.Inet4Address
@@ -30,6 +32,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
private val connectivity by lazy {
service.getSystemService<ConnectivityManager>()
}
private val mainHandler = Handler(Looper.getMainLooper())
private var currentDnsList = listOf<String>()
private val request = NetworkRequest.Builder().apply {
@@ -44,25 +47,42 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networkInfos[network] = NetworkInfo()
updateDns()
scheduleUpdateDns()
}
override fun onLosing(network: Network, maxMsToLive: Int) {
networkInfos[network]?.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
updateDns()
val info = networkInfos[network] ?: return
info.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
scheduleUpdateDns()
if (maxMsToLive > 0) {
mainHandler.postDelayed({
if (networkInfos.containsKey(network)) {
updateDns()
}
}, maxMsToLive.toLong() + 50)
}
}
override fun onLost(network: Network) {
networkInfos.remove(network)
updateDns()
scheduleUpdateDns()
}
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
networkInfos[network]?.dnsList = linkProperties.dnsServers
updateDns()
scheduleUpdateDns()
}
}
private val updateDnsTask = Runnable(::updateDns)
// A switch arrives as a burst, and a network that just came up has no DNS
// until its link properties follow; the Core flushes its cache per list.
private fun scheduleUpdateDns() {
mainHandler.removeCallbacks(updateDnsTask)
mainHandler.postDelayed(updateDnsTask, DNS_UPDATE_DELAY_MILLIS)
}
override fun start() {
updateDns()
connectivity?.registerNetworkCallback(request, callback)
@@ -104,6 +124,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
}
override fun stop() {
mainHandler.removeCallbacksAndMessages(null)
try {
connectivity?.unregisterNetworkCallback(callback)
} finally {
@@ -114,6 +135,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
}
private const val DNS_PORT = 53
private const val DNS_UPDATE_DELAY_MILLIS = 200L
private fun InetAddress.asSocketAddressText(port: Int): String = when (this) {
is Inet6Address -> "[$hostAddress]:$port"
@@ -11,6 +11,7 @@ import androidx.core.content.getSystemService
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
import com.follow.clash.common.QuickAction
import com.follow.clash.common.ensureNotificationChannel
import com.follow.clash.common.quickIntent
import com.follow.clash.common.receiveBroadcastFlow
import com.follow.clash.common.startForeground
@@ -22,9 +23,9 @@ import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.getSpeedTrafficText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
@@ -33,6 +34,7 @@ import kotlinx.coroutines.launch
private data class ExtendedNotificationParams(
val title: String,
val stopText: String,
val showStopAction: Boolean,
val contentText: String,
)
@@ -40,6 +42,7 @@ private val NotificationParams.extended: ExtendedNotificationParams
get() = ExtendedNotificationParams(
title,
stopText,
showStopAction,
Core.getSpeedTrafficText(onlyStatisticsProxy),
)
@@ -48,31 +51,31 @@ internal class NotificationModule(
private val scope: CoroutineScope,
) : ServiceModule {
override fun start() {
service.ensureNotificationChannel()
update(ServiceConfig.notificationParams.value.extended)
scope.launch {
val screenFlow = service.receiveBroadcastFlow {
service.receiveBroadcastFlow {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}.map { intent ->
intent.action == Intent.ACTION_SCREEN_ON
}.onStart {
emit(isScreenOn())
}.distinctUntilChanged().collectLatest { screenOn ->
if (!screenOn) return@collectLatest
combine(
flow {
while (true) {
delay(1_000)
emit(Unit)
}
},
ServiceConfig.notificationParams,
) { _, params ->
params.extended
}.distinctUntilChanged()
.collect(::update)
}
combine(
flow {
while (true) {
delay(1_000)
emit(Unit)
}
},
ServiceConfig.notificationParams,
screenFlow,
) { _, params, screenOn ->
params.takeIf { screenOn }?.extended
}.filterNotNull()
.distinctUntilChanged()
.collect(::update)
}
}
@@ -100,17 +103,18 @@ internal class NotificationModule(
}
}
private val stopIntent by lazy { QuickAction.STOP.quickIntent.toPendingIntent }
private fun update(params: ExtendedNotificationParams) {
service.startForeground(
with(notificationBuilder) {
setContentTitle(params.title)
setContentText(params.contentText)
clearActions()
addAction(
0,
params.stopText,
QuickAction.STOP.quickIntent.toPendingIntent,
).build()
if (params.showStopAction) {
addAction(0, params.stopText, stopIntent)
}
build()
},
)
}
@@ -24,7 +24,6 @@ internal class ServiceModules(private val service: Service) {
val nextModules = listOf(
NotificationModule(service, nextScope),
NetworkObserveModule(service),
SuspendModule(service, nextScope),
)
val startedModules = mutableListOf<ServiceModule>()
@@ -1,47 +0,0 @@
package com.follow.clash.service.modules
import android.app.Service
import android.content.Intent
import android.os.PowerManager
import androidx.core.content.getSystemService
import com.follow.clash.common.receiveBroadcastFlow
import com.follow.clash.core.Core
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
internal class SuspendModule(
private val service: Service,
private val scope: CoroutineScope,
) : ServiceModule {
private fun isScreenOn() =
service.getSystemService<PowerManager>()?.isInteractive ?: true
private val isDeviceIdle: Boolean
get() = service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
private fun updateSuspension(screenOn: Boolean) {
Core.suspended(!screenOn && isDeviceIdle)
}
override fun start() {
scope.launch {
val screenFlow = service.receiveBroadcastFlow {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
}.map {
isScreenOn()
}.onStart {
emit(isScreenOn())
}
screenFlow.collect(::updateSuspension)
}
}
override fun stop() {
Core.suspended(false)
}
}
+3 -3
View File
@@ -6,12 +6,12 @@
android:viewportHeight="240"
tools:ignore="VectorRaster">
<path
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:pathData="M48.1,80.89L168.44,11.41A23.16,23.16 0,0 1,191.6,51.53L71.26,121.01A23.16,23.16 0,0 1,48.1,80.89Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
android:pathData="M78.98,134.37L139.16,99.63A23.16,23.16 0,0 1,162.32,139.75L102.14,174.49A23.16,23.16 0,0 1,78.98,134.37Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:pathData="M98.28,207.92A23.16,23.16 0,1 1,144.6,207.92A23.16,23.16 0,1 1,98.28,207.92Z"
android:fillColor="#FFFFFF"/>
</vector>
@@ -6,12 +6,12 @@
android:viewportHeight="240"
tools:ignore="VectorRaster">
<path
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:pathData="M48.1,80.89L168.44,11.41A23.16,23.16 0,0 1,191.6,51.53L71.26,121.01A23.16,23.16 0,0 1,48.1,80.89Z"
android:fillColor="#6666FB"/>
<path
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
android:pathData="M78.98,134.37L139.16,99.63A23.16,23.16 0,0 1,162.32,139.75L102.14,174.49A23.16,23.16 0,0 1,78.98,134.37Z"
android:fillColor="#336AB6"/>
<path
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:pathData="M98.28,207.92A23.16,23.16 0,1 1,144.6,207.92A23.16,23.16 0,1 1,98.28,207.92Z"
android:fillColor="#5CA8E9"/>
</vector>
+3 -3
View File
@@ -19,10 +19,10 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
id("com.android.application") version "9.4.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.20" apply false
id("com.google.gms.google-services") version "4.5.0" apply false
id("com.google.firebase.crashlytics") version "3.0.7" apply false
id("com.google.firebase.crashlytics") version "3.0.8" apply false
}
include(":app")
@@ -0,0 +1,130 @@
package com.follow.clash.packages
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChinaPackageMatcherTest {
@Test
fun `known vendors and SDKs match`() {
val names = listOf(
"com.tencent.mm",
"com.alipay.android.app",
"com.taobao.taobao",
"com.baidu.searchbox",
"com.bytedance.sdk.openadsdk",
"com.netease.cloudmusic",
"com.unionpay.tsmservice",
"cn.wps.moffice",
"andes.oplus.internal",
)
for (name in names) {
assertTrue(name, ChinaPackageMatcher.matchesKnownPrefix(name))
}
}
@Test
fun `packer signatures used as class names match`() {
val classNames = listOf(
"com.secneo.apkwrapper.H",
"s.h.e.l.l.S",
"com.stub.StubApp",
"com.kiwisec.KiwiSecApplication",
"com.secshell.shellwrapper.SecAppWrapper",
"com.wrapper.proxyapplication.WrapperProxyApplication",
"cn.securitystack.stack.StackApplication",
)
for (name in classNames) {
assertTrue(name, ChinaPackageMatcher.matchesKnownPrefix(name))
}
}
/**
* The prefixes carry no dot boundary on purpose. Adding one would look
* tidier and would silently stop detecting 360 and the Alibaba clouds,
* whose packages extend the prefix without a separator.
*/
@Test
fun `prefixes deliberately match without a separator`() {
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.qihoo360.mobilesafe"))
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.aliyun.linkcard"))
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.alimama.moon"))
}
@Test
fun `a bare prefix matches on its own`() {
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.tencent"))
}
@Test
fun `unrelated packages do not match`() {
val names = listOf(
"org.mozilla.firefox",
"com.spotify.music",
"de.telekom.mail",
"com.whatsapp",
)
for (name in names) {
assertFalse(name, ChinaPackageMatcher.matchesKnownPrefix(name))
}
}
/**
* These two do match a prefix, which is exactly why they have to be skipped
* explicitly: MX Player is caught by `com.mx` (meant for Maxthon) and
* StubHub by `com.stub` (meant for the StubApp packer).
*/
@Test
fun `loose prefixes drag in unrelated apps that the skip list removes`() {
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.mxtech.videoplayer.ad"))
assertTrue(ChinaPackageMatcher.isSkipped("com.mxtech.videoplayer.ad"))
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.stubhub"))
assertTrue(ChinaPackageMatcher.isSkipped("com.stubhub"))
}
@Test
fun `the intended owners of those prefixes still match and are not skipped`() {
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.mx.browser"))
assertFalse(ChinaPackageMatcher.isSkipped("com.mx.browser"))
assertTrue(ChinaPackageMatcher.matchesKnownPrefix("com.stub.StubApp"))
assertFalse(ChinaPackageMatcher.isSkipped("com.stub.StubApp"))
}
@Test
fun `the skip list applies a dot boundary`() {
assertTrue(ChinaPackageMatcher.isSkipped("com.google"))
assertTrue(ChinaPackageMatcher.isSkipped("com.google.android.gms"))
assertFalse(
"com.googlefoo is a different vendor",
ChinaPackageMatcher.isSkipped("com.googlefoo"),
)
}
@Test
fun `skipping wins over a matching prefix`() {
// TikTok ships domestic SDKs but must stay out of the domestic list.
assertTrue(ChinaPackageMatcher.isSkipped("com.zhiliaoapp.musically"))
}
@Test
fun `dex descriptors are normalized before matching`() {
assertEquals(
"com.tencent.mm.Foo.Bar",
ChinaPackageMatcher.classNameOf("Lcom/tencent/mm/Foo\$Bar;"),
)
assertTrue(
ChinaPackageMatcher.matchesKnownPrefix(
ChinaPackageMatcher.classNameOf("Lcom/qihoo360/replugin/Entry;"),
),
)
}
@Test
fun `an already normalized name survives normalization`() {
assertEquals("com.example.Foo", ChinaPackageMatcher.classNameOf("com.example.Foo"))
}
}
@@ -0,0 +1,721 @@
package com.follow.clash
import com.follow.clash.common.AccessControlMode
import com.follow.clash.models.SetupParams
import com.follow.clash.models.SharedState
import com.follow.clash.service.models.AccessControlProps
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
private fun vpnOptions(enable: Boolean = true, stack: String = "gvisor") = VpnOptions(
enable = enable,
port = 7890,
ipv6 = false,
dnsHijacking = false,
accessControlProps = AccessControlProps(
enable = false,
mode = AccessControlMode.ACCEPT_SELECTED,
acceptList = emptyList(),
rejectList = emptyList(),
),
allowBypass = false,
systemProxy = true,
bypassDomain = emptyList(),
stack = stack,
routeAddress = emptyList(),
)
private fun configuredState(enable: Boolean = true, stack: String = "gvisor") = SharedState(
vpnOptions = vpnOptions(enable, stack),
setupParams = SetupParams(testUrl = "https://example.com", selectedMap = emptyMap()),
)
private class FakeTile : TileGateway {
var startCount = 0
var stopCount = 0
override fun handleStart() {
startCount++
}
override fun handleStop() {
stopCount++
}
}
private class FakeApp(
private val notificationGranted: Boolean = true,
private val localNetworkGranted: Boolean = true,
private val vpnGranted: Boolean = true,
private val holdVpnPreparation: Boolean = false,
) : AppGateway {
var beforeVpnPrepared: (() -> Unit)? = null
var cancelledPreparations = 0
var localNetworkRequests = 0
private var heldCallback: ((Boolean) -> Unit)? = null
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
callback(notificationGranted)
override fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) {
localNetworkRequests++
callback(localNetworkGranted)
}
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) {
beforeVpnPrepared?.invoke()
if (holdVpnPreparation) {
heldCallback = callback
return
}
callback(vpnGranted)
}
override fun cancelVpnPreparation(callback: (Boolean) -> Unit) {
cancelledPreparations++
if (heldCallback === callback) {
heldCallback = null
}
}
}
private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
var storedSharedState = configuredState()
var setupResult: Result<String> = Result.success("")
var startResult = 1_700_000_000_000L
var vpnPermissionGranted = true
var localNetworkPermissionGranted = true
var vpnServiceActive = true
var tile: TileGateway? = null
var app: AppGateway? = null
var beforeStartService: (() -> Unit)? = null
var lastStartOptions: VpnOptions? = null
override var runTimeMillis = 0L
override val homeDirPath = "/data/user/0/com.follow.clash/files"
override val sdkInt = 34
val toasts = mutableListOf<String>()
val logs = mutableListOf<String>()
val notificationParams = mutableListOf<NotificationParams>()
val crashlytics = mutableListOf<Boolean>()
var setupCalls = 0
var startCalls = 0
var stopCalls = 0
var lastInitParams: String? = null
var lastSetupParams: String? = null
override fun log(message: String) {
logs += message
}
override fun showToast(message: String) {
toasts += message
}
override fun setCrashlytics(enabled: Boolean) {
crashlytics += enabled
}
override fun updateNotificationParams(params: NotificationParams) {
notificationParams += params
}
override fun loadSharedState(): SharedState = storedSharedState
override fun isVpnPermissionGranted(): Boolean = vpnPermissionGranted
override fun isLocalNetworkPermissionGranted(): Boolean = localNetworkPermissionGranted
override fun tile(): TileGateway? = tile
override fun app(): AppGateway? = app
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> {
setupCalls++
lastInitParams = initParams
lastSetupParams = setupParams
return setupResult
}
override suspend fun startService(options: VpnOptions): Long {
startCalls++
lastStartOptions = options
beforeStartService?.invoke()
runTimeMillis = startResult
return startResult
}
override suspend fun stopService() {
stopCalls++
runTimeMillis = 0L
}
override suspend fun isVpnServiceActive(): Boolean = vpnServiceActive
}
@OptIn(ExperimentalCoroutinesApi::class)
class ServiceStateMachineTest {
@Test
fun `initParams spells the keys the Go wrapper expects`() {
val json = ServiceStateMachine.initParams("/files", 34)
assertEquals("""{"home-dir":"/files","version":34}""", json)
}
@Test
fun `notification params come straight off the shared state`() {
val params = ServiceStateMachine.notificationParams(
SharedState(
currentProfileName = "Work",
stopText = "Disconnect",
onlyStatisticsProxy = true,
),
)
assertEquals(NotificationParams("Work", "Disconnect", true), params)
}
@Test
fun `notification params carry the stop action switch`() {
val params = ServiceStateMachine.notificationParams(
SharedState(showStopAction = false),
)
assertEquals(false, params.showStopAction)
}
@Test
fun `refresh reports STARTED only while the service has a run time`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
assertEquals(0L, machine.refresh())
assertEquals(RunState.STOPPED, machine.runState.value)
host.runTimeMillis = 42L
assertEquals(42L, machine.refresh())
assertEquals(RunState.STARTED, machine.runState.value)
}
@Test
fun `a start request drives the service to STARTED`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
assertTrue(machine.requestStart().await())
assertEquals(RunState.STARTED, machine.runState.value)
assertEquals(1, host.startCalls)
}
@Test
fun `a start that the service refuses settles back to STOPPED`() = runTest {
val host = FakeHost(backgroundScope)
host.startResult = 0L
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
assertFalse(machine.requestStart().await())
assertEquals(RunState.STOPPED, machine.runState.value)
}
@Test
fun `a start without stored vpn options never reaches the service`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
assertFalse(machine.requestStart().await())
assertEquals(0, host.startCalls)
}
@Test
fun `a denied notification permission cancels the start`() = runTest {
val host = FakeHost(backgroundScope)
host.app = FakeApp(notificationGranted = false)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
assertFalse(machine.requestStart().await())
assertEquals(0, host.startCalls)
assertEquals(RunState.STOPPED, machine.runState.value)
}
@Test
fun `a denied vpn permission cancels the start`() = runTest {
val host = FakeHost(backgroundScope)
host.app = FakeApp(vpnGranted = false)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
assertFalse(machine.requestStart().await())
assertEquals(0, host.startCalls)
}
/**
* Without a foreground app there is nobody to show the system consent dialog, so the machine
* has to explain the refusal itself.
*/
@Test
fun `a missing vpn permission is reported when no app is attached`() = runTest {
val host = FakeHost(backgroundScope)
host.vpnPermissionGranted = false
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
assertFalse(machine.requestStart().await())
assertTrue(host.toasts.contains(VPN_PERMISSION_MESSAGE))
}
@Test
fun `a proxy-only start does not need the vpn permission`() = runTest {
val host = FakeHost(backgroundScope)
host.vpnPermissionGranted = false
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState(enable = false))
assertTrue(machine.requestStart().await())
assertEquals(1, host.startCalls)
}
/** Android 17 drops the kernel hop system/mixed rely on until the permission is granted. */
@Test
fun `a denied local network permission falls back to the gvisor stack`() = runTest {
val host = FakeHost(backgroundScope)
host.localNetworkPermissionGranted = false
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState(stack = "system"))
assertTrue(machine.requestStart().await())
assertEquals("gvisor", host.lastStartOptions?.stack)
assertTrue(host.toasts.contains(configuredState().localNetworkTip))
}
@Test
fun `a granted local network permission keeps the chosen stack`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState(stack = "mixed"))
assertTrue(machine.requestStart().await())
assertEquals("mixed", host.lastStartOptions?.stack)
assertFalse(host.toasts.contains(configuredState().localNetworkTip))
}
@Test
fun `a proxy-only start keeps its stack without the local network permission`() = runTest {
val host = FakeHost(backgroundScope)
host.localNetworkPermissionGranted = false
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState(enable = false, stack = "system"))
assertTrue(machine.requestStart().await())
assertEquals("system", host.lastStartOptions?.stack)
assertFalse(host.toasts.contains(configuredState().localNetworkTip))
}
@Test
fun `the local network prompt is asked once and never blocks the start`() = runTest {
val host = FakeHost(backgroundScope)
val app = FakeApp(localNetworkGranted = false)
host.app = app
host.localNetworkPermissionGranted = false
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState(stack = "system"))
assertTrue(machine.requestStart().await())
assertEquals(1, app.localNetworkRequests)
assertEquals(1, host.startCalls)
assertEquals("gvisor", host.lastStartOptions?.stack)
}
@Test
fun `a stop request drives the service to STOPPED`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
machine.requestStart().await()
assertTrue(machine.requestStop().await())
assertEquals(RunState.STOPPED, machine.runState.value)
assertEquals(1, host.stopCalls)
}
@Test
fun `stopping an already stopped service touches nothing`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
assertTrue(machine.requestStop().await())
assertEquals(0, host.stopCalls)
}
@Test
fun `a stop that lands mid-preparation keeps the service stopped`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
val app = FakeApp()
host.app = app
app.beforeVpnPrepared = { machine.requestStop() }
assertFalse(machine.requestStart().await())
assertEquals(0, host.startCalls)
assertEquals(RunState.STOPPED, machine.runState.value)
}
@Test
fun `a start reports failure when a stop overtakes the service call`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
host.beforeStartService = { machine.requestStop() }
assertFalse(machine.requestStart().await())
}
@Test
fun `handleServiceLost clears the state for the token that observed the loss`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
machine.requestStart().await()
val token = machine.captureRequestToken()
host.runTimeMillis = 0L
machine.handleServiceLost(token)
assertEquals(RunState.STOPPED, machine.runState.value)
}
@Test
fun `handleServiceLost keeps the intent of a start that raced ahead of it`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
val staleToken = machine.captureRequestToken()
machine.requestStart().await()
host.runTimeMillis = 0L
machine.handleServiceLost(staleToken)
assertEquals(RunState.STARTED, machine.runState.value)
}
@Test
fun `handleServiceLost ignores a service that is running again`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
machine.requestStart().await()
machine.handleServiceLost(machine.captureRequestToken())
assertEquals(RunState.STARTED, machine.runState.value)
}
@Test
fun `handleStartAction hands the start to the tile when one is attached`() = runTest {
val host = FakeHost(backgroundScope)
val tile = FakeTile()
host.tile = tile
val machine = ServiceStateMachine(host)
machine.handleStartAction()
assertEquals(1, tile.startCount)
assertEquals(0, host.setupCalls)
}
@Test
fun `handleStartAction is a no-op while a run is already requested`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
machine.requestStart().await()
val startsBefore = host.startCalls
machine.handleStartAction()
assertEquals(startsBefore, host.startCalls)
assertEquals(0, host.setupCalls)
}
@Test
fun `handleStartAction reports a missing configuration`() = runTest {
val host = FakeHost(backgroundScope)
host.storedSharedState = SharedState()
val machine = ServiceStateMachine(host)
machine.handleStartAction()
assertEquals(listOf(MISSING_CONFIG_MESSAGE), host.toasts)
assertEquals(0, host.setupCalls)
}
@Test
fun `handleStartAction loads the stored configuration and sets the core up`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.handleStartAction()
assertEquals(1, host.setupCalls)
assertEquals(1, host.startCalls)
assertEquals(RunState.STARTED, machine.runState.value)
assertEquals(
ServiceStateMachine.initParams(host.homeDirPath, host.sdkInt),
host.lastInitParams,
)
assertEquals(Gson().toJson(host.storedSharedState.setupParams), host.lastSetupParams)
}
@Test
fun `a core that rejects the configuration reports its own message`() = runTest {
val host = FakeHost(backgroundScope)
host.setupResult = Result.success("proxy group not found")
val machine = ServiceStateMachine(host)
machine.handleStartAction()
assertTrue(host.toasts.contains("proxy group not found"))
assertEquals(0, host.startCalls)
}
@Test
fun `a core setup that throws without a message falls back to the generic one`() = runTest {
val host = FakeHost(backgroundScope)
host.setupResult = Result.failure(RuntimeException(" "))
val machine = ServiceStateMachine(host)
machine.handleStartAction()
assertTrue(host.toasts.contains(INVALID_CONFIG_MESSAGE))
assertEquals(0, host.startCalls)
}
@Test
fun `a service that refuses the start after a good setup is reported`() = runTest {
val host = FakeHost(backgroundScope)
host.startResult = 0L
val machine = ServiceStateMachine(host)
machine.handleStartAction()
assertTrue(host.toasts.contains(START_FAILED_MESSAGE))
}
@Test
fun `handleStopAction hands the stop to the tile when one is attached`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
machine.requestStart().await()
val tile = FakeTile()
host.tile = tile
machine.handleStopAction()
assertEquals(1, tile.stopCount)
assertEquals(0, host.stopCalls)
}
@Test
fun `handleStopAction is a no-op while nothing is running`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.handleStopAction()
assertEquals(0, host.stopCalls)
assertTrue(host.toasts.isEmpty())
}
@Test
fun `handleStopAction announces itself before stopping`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState().copy(stopTip = "Stopping..."))
machine.requestStart().await()
machine.handleStopAction()
assertEquals(listOf("Stopping..."), host.toasts)
assertEquals(1, host.stopCalls)
}
@Test
fun `handleToggleAction starts when stopped and stops when started`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.handleToggleAction()
assertEquals(1, host.startCalls)
machine.handleToggleAction()
assertEquals(1, host.stopCalls)
}
@Test
fun `a revoke is ignored while no vpn service is active`() = runTest {
val host = FakeHost(backgroundScope)
host.vpnServiceActive = false
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
machine.requestStart().await()
machine.handleVpnRevokeAction()
assertEquals(0, host.stopCalls)
}
@Test
fun `a revoke stops the active vpn service`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
machine.requestStart().await()
machine.handleVpnRevokeAction()
assertEquals(1, host.stopCalls)
}
@Test
fun `syncSharedState pushes crashlytics and the notification straight through`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(
SharedState(
crashlytics = false,
currentProfileName = "Work",
stopText = "Disconnect",
onlyStatisticsProxy = true,
),
)
assertEquals(listOf(false), host.crashlytics)
assertEquals(listOf(NotificationParams("Work", "Disconnect", true)), host.notificationParams)
}
@Test
fun `a start that a stop overtakes never announces STARTED`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
val states = mutableListOf<RunState>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
machine.runState.toList(states)
}
host.beforeStartService = { machine.requestStop() }
assertFalse(machine.requestStart().await())
testScheduler.runCurrent()
assertFalse(states.contains(RunState.STARTED))
assertEquals(RunState.STOPPED, machine.runState.value)
assertEquals(0L, host.runTimeMillis)
}
@Test
fun `a start rolled back after the service came up stops it again`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
host.beforeStartService = {
host.app = FakeApp(vpnGranted = false)
machine.requestStart()
}
assertFalse(machine.requestStart().await())
testScheduler.runCurrent()
assertEquals(1, host.stopCalls)
assertEquals(0L, host.runTimeMillis)
assertEquals(RunState.STOPPED, machine.runState.value)
assertFalse(machine.captureRequestToken().running)
}
@Test
fun `a start that overtakes another inside the binding window adopts the running service`() =
runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
host.beforeStartService = {
host.beforeStartService = null
machine.requestStart()
}
machine.requestStart().await()
testScheduler.runCurrent()
assertEquals(1, host.startCalls)
assertEquals(0, host.stopCalls)
assertEquals(RunState.STARTED, machine.runState.value)
assertTrue(machine.captureRequestToken().running)
}
@Test
fun `a start rebinds when the running service is not the one the options ask for`() = runTest {
val host = FakeHost(backgroundScope)
host.vpnServiceActive = false
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
host.beforeStartService = {
host.beforeStartService = null
machine.requestStart()
}
machine.requestStart().await()
testScheduler.runCurrent()
assertEquals(2, host.startCalls)
assertEquals(RunState.STARTED, machine.runState.value)
}
@Test
fun `a stop releases a start that is waiting on the vpn consent`() = runTest {
val host = FakeHost(backgroundScope)
val app = FakeApp(holdVpnPreparation = true)
host.app = app
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
val start = machine.requestStart()
testScheduler.runCurrent()
assertEquals(0, host.startCalls)
assertTrue(machine.requestStop().await())
assertFalse(start.await())
assertEquals(0, host.startCalls)
assertEquals(1, app.cancelledPreparations)
assertEquals(RunState.STOPPED, machine.runState.value)
}
@Test
fun `a start request that throws is logged and rolled back`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState())
host.beforeStartService = { throw IllegalStateException("binder died") }
assertFalse(machine.requestStart().await())
assertTrue(host.logs.any { it.contains("binder died") })
assertFalse(machine.captureRequestToken().running)
}
}
+158
View File
@@ -0,0 +1,158 @@
package com.follow.clash.models
import com.follow.clash.common.AccessControlMode
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* `SharedState` is written by Flutter into `FlutterSharedPreferences` and read back here, so the
* JSON spelling of every field is a cross-language contract rather than an implementation detail.
*/
class SharedStateTest {
private val gson = Gson()
@Test
fun `setup params use the kebab-case spelling Flutter writes`() {
val json = """{"test-url":"https://example.test/204","selected-map":{"GLOBAL":"auto"}}"""
val params = gson.fromJson(json, SetupParams::class.java)
assertEquals("https://example.test/204", params.testUrl)
assertEquals(mapOf("GLOBAL" to "auto"), params.selectedMap)
}
@Test
fun `setup params serialize back to the same spelling`() {
val encoded = gson.toJson(
SetupParams(testUrl = "https://example.test", selectedMap = emptyMap()),
)
assertTrue(encoded.contains("\"test-url\""))
assertTrue(encoded.contains("\"selected-map\""))
assertTrue(!encoded.contains("testUrl"))
assertTrue(!encoded.contains("selectedMap"))
}
@Test
fun `a full payload round-trips including nested vpn options`() {
val json = """
{
"startTip": "Starting",
"stopTip": "Stopping",
"crashlytics": false,
"currentProfileName": "Work",
"stopText": "Halt",
"onlyStatisticsProxy": true,
"showStopAction": false,
"vpnOptions": {
"enable": true,
"port": 7890,
"ipv6": false,
"dnsHijacking": true,
"accessControlProps": {
"enable": true,
"mode": "rejectSelected",
"acceptList": ["a.b"],
"rejectList": ["c.d"]
},
"allowBypass": false,
"systemProxy": true,
"bypassDomain": ["example.test"],
"stack": "gvisor",
"routeAddress": ["0.0.0.0/0"]
},
"setupParams": {
"test-url": "https://example.test/204",
"selected-map": {"GLOBAL": "auto"}
}
}
""".trimIndent()
val state = gson.fromJson(json, SharedState::class.java)
assertEquals("Starting", state.startTip)
assertEquals("Work", state.currentProfileName)
assertEquals(false, state.crashlytics)
assertEquals(true, state.onlyStatisticsProxy)
assertEquals(false, state.showStopAction)
assertEquals(7890, state.vpnOptions?.port)
assertEquals("gvisor", state.vpnOptions?.stack)
assertEquals(
AccessControlMode.REJECT_SELECTED,
state.vpnOptions?.accessControlProps?.mode,
)
assertEquals(listOf("0.0.0.0/0"), state.vpnOptions?.routeAddress)
assertEquals("https://example.test/204", state.setupParams?.testUrl)
}
@Test
fun `the constructed default keeps every fallback Flutter relies on`() {
val defaults = SharedState()
assertEquals("FlClash", defaults.currentProfileName)
assertEquals("Stop", defaults.stopText)
assertEquals(true, defaults.crashlytics)
assertEquals(false, defaults.onlyStatisticsProxy)
assertEquals(true, defaults.showStopAction)
assertNull(defaults.vpnOptions)
assertNull(defaults.setupParams)
}
@Test
fun `an empty document still yields the declared defaults`() {
// Every SharedState parameter has a default, so Kotlin emits a no-arg constructor
// that Gson uses instead of its Unsafe fallback. Losing a default on any one
// parameter would silently turn the absent fields below into nulls.
val state = gson.fromJson("{}", SharedState::class.java)
assertNotNull(state)
assertEquals("Starting VPN...", state.startTip)
assertEquals("FlClash", state.currentProfileName)
assertEquals(true, state.crashlytics)
assertNull(state.vpnOptions)
assertNull(state.setupParams)
}
@Test
fun `a partial document keeps defaults for the fields it omits`() {
val state = gson.fromJson("""{"currentProfileName":"Work"}""", SharedState::class.java)
assertEquals("Work", state.currentProfileName)
assertEquals("Stop", state.stopText)
assertEquals("Stopping VPN...", state.stopTip)
}
@Test
fun `setup params have no defaults so absent fields decode as null`() {
// SetupParams declares no default values, so Gson builds it through Unsafe and
// leaves missing fields null despite the non-nullable Kotlin types.
val params = gson.fromJson("{}", SetupParams::class.java)
@Suppress("SENSELESS_COMPARISON")
assertTrue(params.testUrl == null)
@Suppress("SENSELESS_COMPARISON")
assertTrue(params.selectedMap == null)
}
@Test
fun `a null json document decodes to null so the caller can fall back`() {
assertNull(gson.fromJson("null", SharedState::class.java))
assertNull(gson.fromJson(null as String?, SharedState::class.java))
}
@Test
fun `an access control payload without a matching mode decodes to null`() {
val json = """
{"vpnOptions":{"accessControlProps":{"mode":"unknownMode"}}}
""".trimIndent()
val state = gson.fromJson(json, SharedState::class.java)
assertNull(state.vpnOptions?.accessControlProps?.mode)
}
}
+102
View File
@@ -0,0 +1,102 @@
package com.follow.clash.common
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BroadcastLeaseTest {
@Test
fun `starts held`() {
val lease = BroadcastLease { }
assertFalse(lease.isReleased)
}
@Test
fun `the first release runs the release action and reports the win`() {
var releases = 0
val lease = BroadcastLease { releases++ }
assertTrue(lease.release())
assertEquals(1, releases)
assertTrue(lease.isReleased)
}
@Test
fun `a second release neither runs nor claims the win`() {
var releases = 0
val lease = BroadcastLease { releases++ }
lease.release()
assertFalse(lease.release())
assertEquals(1, releases)
}
@Test
fun `the reason runs only for the caller that wins`() {
val reasons = mutableListOf<String>()
val lease = BroadcastLease { }
lease.release { reasons.add("timeout") }
lease.release { reasons.add("completion") }
assertEquals(listOf("timeout"), reasons)
}
@Test
fun `the reason runs before the lease is released`() {
val order = mutableListOf<String>()
val lease = BroadcastLease { order.add("release") }
lease.release { order.add("reason") }
assertEquals(listOf("reason", "release"), order)
}
@Test
fun `the lease is still released when the reason throws`() {
var releases = 0
val lease = BroadcastLease { releases++ }
try {
lease.release { throw RuntimeException("boom") }
} catch (_: RuntimeException) {
// expected: propagated after the lease is released
}
assertEquals(1, releases)
assertTrue(lease.isReleased)
}
@Test
fun `only one of many concurrent releases wins`() {
val threads = 16
val releases = AtomicInteger(0)
val wins = AtomicInteger(0)
val lease = BroadcastLease { releases.incrementAndGet() }
val start = CountDownLatch(1)
val done = CountDownLatch(threads)
val pool = Executors.newFixedThreadPool(threads)
repeat(threads) {
pool.execute {
start.await()
if (lease.release()) {
wins.incrementAndGet()
}
done.countDown()
}
}
start.countDown()
assertTrue(done.await(5, TimeUnit.SECONDS))
pool.shutdown()
assertEquals(1, releases.get())
assertEquals(1, wins.get())
}
}
+47
View File
@@ -0,0 +1,47 @@
package com.follow.clash.common
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The Flutter layer exchanges these enums as JSON, so the serialized spelling is a cross-language
* contract rather than an implementation detail.
*/
class EnumsTest {
private val gson = Gson()
@Test
fun `access control mode serializes to the Dart spelling`() {
assertEquals("\"acceptSelected\"", gson.toJson(AccessControlMode.ACCEPT_SELECTED))
assertEquals("\"rejectSelected\"", gson.toJson(AccessControlMode.REJECT_SELECTED))
}
@Test
fun `access control mode deserializes from the Dart spelling`() {
assertEquals(
AccessControlMode.ACCEPT_SELECTED,
gson.fromJson("\"acceptSelected\"", AccessControlMode::class.java),
)
assertEquals(
AccessControlMode.REJECT_SELECTED,
gson.fromJson("\"rejectSelected\"", AccessControlMode::class.java),
)
}
@Test
fun `quick action names back the intent action suffixes`() {
assertEquals(
listOf("STOP", "START", "TOGGLE"),
QuickAction.entries.map { it.name },
)
}
@Test
fun `broadcast action names back the intent action suffixes`() {
assertEquals(
listOf("VPN_START_REQUESTED", "VPN_REVOKED"),
BroadcastAction.entries.map { it.name },
)
}
}
+164
View File
@@ -0,0 +1,164 @@
package com.follow.clash.common
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PendingCallbackTest {
@Test
fun `starts empty`() {
val slot = PendingCallback<Boolean>()
assertFalse(slot.isPending)
}
@Test
fun `replace leaves the new callback pending`() {
val slot = PendingCallback<Boolean>()
slot.replace({ }, supersededValue = false)
assertTrue(slot.isPending)
}
@Test
fun `resolve delivers the value once and empties the slot`() {
val slot = PendingCallback<Boolean>()
val received = mutableListOf<Boolean>()
slot.replace({ received.add(it) }, supersededValue = false)
slot.resolve(true)
slot.resolve(true)
assertEquals(listOf(true), received)
assertFalse(slot.isPending)
}
@Test
fun `replace settles the request it supersedes`() {
val slot = PendingCallback<Boolean>()
val first = mutableListOf<Boolean>()
val second = mutableListOf<Boolean>()
slot.replace({ first.add(it) }, supersededValue = false)
slot.replace({ second.add(it) }, supersededValue = false)
assertEquals(listOf(false), first)
assertTrue(second.isEmpty())
slot.resolve(true)
assertEquals(listOf(false), first)
assertEquals(listOf(true), second)
}
@Test
fun `resolve on an empty slot is inert`() {
val slot = PendingCallback<Boolean>()
slot.resolve(true)
assertFalse(slot.isPending)
}
@Test
fun `cancel drops the matching callback without invoking it`() {
val slot = PendingCallback<Boolean>()
val received = mutableListOf<Boolean>()
val callback: (Boolean) -> Unit = { received.add(it) }
slot.replace(callback, supersededValue = false)
slot.cancel(callback)
assertFalse(slot.isPending)
assertTrue(received.isEmpty())
}
@Test
fun `cancel keeps a callback that is no longer the pending one`() {
val slot = PendingCallback<Boolean>()
val stale = mutableListOf<Boolean>()
val current = mutableListOf<Boolean>()
val staleCallback: (Boolean) -> Unit = { stale.add(it) }
slot.replace(staleCallback, supersededValue = false)
slot.replace({ current.add(it) }, supersededValue = false)
slot.cancel(staleCallback)
assertTrue(slot.isPending)
slot.resolve(true)
assertEquals(listOf(false), stale)
assertEquals(listOf(true), current)
}
@Test
fun `a callback that starts a new request is not resolved twice`() {
val slot = PendingCallback<Boolean>()
val outer = mutableListOf<Boolean>()
val inner = mutableListOf<Boolean>()
slot.replace(
{
outer.add(it)
slot.replace({ value -> inner.add(value) }, supersededValue = false)
},
supersededValue = false,
)
slot.resolve(true)
assertEquals(listOf(true), outer)
assertTrue(inner.isEmpty())
assertTrue(slot.isPending)
slot.resolve(false)
assertEquals(listOf(true), outer)
assertEquals(listOf(false), inner)
}
/**
* The request is started off the main thread and the answer arrives on it, so
* a resolve that has already read the slot must not clear the callback a
* concurrent replace installed after that read. Losing it strands the request
* that callback belongs to, which on the VPN consent hop means the start path
* never releases its lock.
*/
@Test
fun `a callback installed while a resolve is in flight is never dropped`() {
repeat(200) {
val slot = PendingCallback<Boolean>()
val settled = AtomicInteger()
slot.replace({ settled.incrementAndGet() }, supersededValue = false)
val ready = CountDownLatch(2)
val go = CountDownLatch(1)
val replacer = Thread {
ready.countDown()
go.await()
slot.replace({ settled.incrementAndGet() }, supersededValue = false)
}
val resolver = Thread {
ready.countDown()
go.await()
slot.resolve(true)
}
replacer.start()
resolver.start()
ready.await()
go.countDown()
replacer.join(TimeUnit.SECONDS.toMillis(5))
resolver.join(TimeUnit.SECONDS.toMillis(5))
// Two callbacks were installed and at most one of them can still be
// pending, so every callback the slot let go of must have been called.
val pending = if (slot.isPending) 1 else 0
assertEquals(2 - pending, settled.get())
}
}
}
@@ -0,0 +1,137 @@
package com.follow.clash.common
import java.util.concurrent.CyclicBarrier
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class RunIntentArbiterTest {
@Test
fun `starts stopped by default`() {
val arbiter = RunIntentArbiter()
assertFalse(arbiter.isRunningRequested)
assertFalse(arbiter.current().running)
}
@Test
fun `request publishes the new intent`() {
val arbiter = RunIntentArbiter()
val token = arbiter.request(running = true)
assertTrue(arbiter.isRunningRequested)
assertTrue(arbiter.isCurrent(token))
assertSame(token, arbiter.current())
}
@Test
fun `each request mints a distinct token`() {
val arbiter = RunIntentArbiter()
val first = arbiter.request(running = true)
val second = arbiter.request(running = true)
assertNotSame(first, second)
assertFalse(arbiter.isCurrent(first))
assertTrue(arbiter.isCurrent(second))
}
@Test
fun `a newer request supersedes work started for an older token`() {
val arbiter = RunIntentArbiter()
val start = arbiter.request(running = true)
val stop = arbiter.request(running = false)
assertFalse(arbiter.isCurrent(start))
assertTrue(arbiter.isCurrent(stop))
assertFalse(arbiter.isRunningRequested)
}
@Test
fun `resetToStopped rolls back the current token`() {
val arbiter = RunIntentArbiter()
val token = arbiter.request(running = true)
assertTrue(arbiter.resetToStopped(token))
assertFalse(arbiter.isRunningRequested)
assertFalse(arbiter.isCurrent(token))
}
@Test
fun `resetToStopped does not clobber a newer intent`() {
val arbiter = RunIntentArbiter()
val stale = arbiter.request(running = true)
val latest = arbiter.request(running = true)
assertFalse(arbiter.resetToStopped(stale))
assertTrue(arbiter.isRunningRequested)
assertTrue(arbiter.isCurrent(latest))
}
@Test
fun `resetToStopped is not idempotent for the same token`() {
val arbiter = RunIntentArbiter()
val token = arbiter.request(running = true)
assertTrue(arbiter.resetToStopped(token))
assertFalse(arbiter.resetToStopped(token))
}
@Test
fun `a start racing ahead of a service-lost callback keeps its intent`() {
val arbiter = RunIntentArbiter()
val observed = arbiter.request(running = true)
// The service-loss callback captured `observed`, but a new start won the race first.
val restart = arbiter.request(running = true)
val settled = arbiter.resetToStopped(observed)
assertFalse(settled)
assertTrue(arbiter.isRunningRequested)
assertTrue(arbiter.isCurrent(restart))
}
@Test
fun `initialRunning seeds the first intent`() {
val arbiter = RunIntentArbiter(initialRunning = true)
assertTrue(arbiter.isRunningRequested)
}
@Test
fun `concurrent requests leave exactly one winning token`() {
val arbiter = RunIntentArbiter()
val threads = 8
val executor = Executors.newFixedThreadPool(threads)
val barrier = CyclicBarrier(threads)
try {
val tokens = (0 until threads).map { index ->
executor.submit<RunIntentArbiter.Token> {
barrier.await(5, TimeUnit.SECONDS)
arbiter.request(running = index % 2 == 0)
}
}.map { it.get(5, TimeUnit.SECONDS) }
val winners = tokens.filter(arbiter::isCurrent)
assertEquals(1, winners.size)
assertEquals(winners.single().running, arbiter.isRunningRequested)
tokens.filterNot(arbiter::isCurrent).forEach { stale ->
assertFalse(arbiter.resetToStopped(stale))
}
assertTrue(arbiter.isCurrent(winners.single()))
} finally {
executor.shutdownNow()
}
}
}
@@ -0,0 +1,77 @@
package com.follow.clash.service
import com.follow.clash.common.AccessControlMode
import com.follow.clash.service.models.AccessControlProps
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Test
private fun vpnOptions(port: Int) = VpnOptions(
enable = true,
port = port,
ipv6 = false,
dnsHijacking = true,
accessControlProps = AccessControlProps(
enable = true,
mode = AccessControlMode.REJECT_SELECTED,
acceptList = listOf("com.example.accepted"),
rejectList = listOf("com.example.rejected"),
),
allowBypass = true,
systemProxy = false,
bypassDomain = listOf("example.test"),
stack = "system",
routeAddress = listOf("0.0.0.0/0"),
)
class ServiceConfigTest {
@Test
fun `notification params default to the app name and stop label`() {
val defaults = NotificationParams()
assertEquals("FlClash", defaults.title)
assertEquals("STOP", defaults.stopText)
assertEquals(false, defaults.onlyStatisticsProxy)
assertEquals(true, defaults.showStopAction)
}
@Test
fun `updateVpnOptions publishes the latest options`() {
ServiceConfig.updateVpnOptions(vpnOptions(7890))
assertEquals(7890, ServiceConfig.vpnOptions?.port)
val latest = vpnOptions(7891)
ServiceConfig.updateVpnOptions(latest)
assertSame(latest, ServiceConfig.vpnOptions)
}
@Test
fun `updateNotificationParams emits through the state flow`() = runTest {
val params = NotificationParams(
title = "Profile",
stopText = "Halt",
onlyStatisticsProxy = true,
)
ServiceConfig.updateNotificationParams(params)
assertSame(params, ServiceConfig.notificationParams.value)
}
@Test
fun `notification params state flow keeps the newest value`() = runTest {
val first = NotificationParams(title = "first")
val second = NotificationParams(title = "second")
ServiceConfig.updateNotificationParams(first)
ServiceConfig.updateNotificationParams(second)
assertSame(second, ServiceConfig.notificationParams.value)
assertNotSame(first, ServiceConfig.notificationParams.value)
}
}
+67
View File
@@ -0,0 +1,67 @@
package com.follow.clash.service.models
import java.util.Locale
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class TrafficTest {
private lateinit var defaultLocale: Locale
@Before
fun pinLocale() {
// speedText formats through String.format, which follows the default locale.
defaultLocale = Locale.getDefault()
Locale.setDefault(Locale.ROOT)
}
@After
fun restoreLocale() {
Locale.setDefault(defaultLocale)
}
@Test
fun `renders whole bytes without a decimal part`() {
assertEquals("0B/s↑ 0B/s↓", Traffic(up = 0, down = 0).speedText)
assertEquals("1023B/s↑ 1B/s↓", Traffic(up = 1023, down = 1).speedText)
}
@Test
fun `promotes to the next unit at 1024`() {
assertEquals("1.0KB/s↑ 1.0KB/s↓", Traffic(up = 1024, down = 1024).speedText)
}
@Test
fun `keeps one decimal place for fractional values`() {
assertEquals("1.5KB/s↑ 2.5KB/s↓", Traffic(up = 1536, down = 2560).speedText)
}
@Test
fun `scales through every unit`() {
assertTrue(Traffic(up = 1024L * 1024, down = 0).speedText.startsWith("1.0MB"))
assertTrue(
Traffic(up = 1024L * 1024 * 1024, down = 0).speedText.startsWith("1.0GB"),
)
assertTrue(
Traffic(up = 1024L * 1024 * 1024 * 1024, down = 0).speedText
.startsWith("1.0TB"),
)
}
@Test
fun `stops promoting above terabytes`() {
val text = Traffic(up = 1024L * 1024 * 1024 * 1024 * 1024, down = 0).speedText
assertTrue(text.startsWith("1024.0TB"))
}
@Test
fun `labels upload before download`() {
val text = Traffic(up = 1, down = 2).speedText
assertTrue(text.indexOf("↑") < text.indexOf("↓"))
assertEquals("1B/s↑ 2B/s↓", text)
}
}
+144
View File
@@ -0,0 +1,144 @@
package com.follow.clash.service.models
import com.follow.clash.common.AccessControlMode
import java.net.Inet4Address
import java.net.Inet6Address
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
private fun optionsWithRoutes(routeAddress: List<String>) = VpnOptions(
enable = true,
port = 7890,
ipv6 = true,
dnsHijacking = false,
accessControlProps = AccessControlProps(
enable = false,
mode = AccessControlMode.ACCEPT_SELECTED,
acceptList = emptyList(),
rejectList = emptyList(),
),
allowBypass = false,
systemProxy = true,
bypassDomain = emptyList(),
stack = "gvisor",
routeAddress = routeAddress,
)
class ToCIDRTest {
@Test
fun `parses an IPv4 network`() {
val cidr = "192.168.1.0/24".toCIDR()
assertTrue(cidr.address is Inet4Address)
assertEquals("192.168.1.0", cidr.address.hostAddress)
assertEquals(24, cidr.prefixLength)
}
@Test
fun `parses an IPv6 network`() {
val cidr = "fd00::/8".toCIDR()
assertTrue(cidr.address is Inet6Address)
assertEquals(8, cidr.prefixLength)
}
@Test
fun `accepts the boundary prefix lengths`() {
assertEquals(0, "0.0.0.0/0".toCIDR().prefixLength)
assertEquals(32, "10.0.0.1/32".toCIDR().prefixLength)
assertEquals(0, "::/0".toCIDR().prefixLength)
assertEquals(128, "fd00::1/128".toCIDR().prefixLength)
}
@Test
fun `rejects an address without a prefix`() {
val error = assertThrows(IllegalArgumentException::class.java) {
"192.168.1.0".toCIDR()
}
assertTrue(error.message!!.contains("Invalid CIDR format"))
}
@Test
fun `rejects an address with too many segments`() {
assertThrows(IllegalArgumentException::class.java) {
"192.168.1.0/24/8".toCIDR()
}
}
@Test
fun `rejects a non-numeric prefix`() {
val error = assertThrows(IllegalArgumentException::class.java) {
"192.168.1.0/abc".toCIDR()
}
assertTrue(error.message!!.contains("Invalid prefix length"))
}
@Test
fun `rejects an IPv4 prefix above 32`() {
assertThrows(IllegalArgumentException::class.java) {
"192.168.1.0/33".toCIDR()
}
}
@Test
fun `rejects an IPv6 prefix above 128`() {
assertThrows(IllegalArgumentException::class.java) {
"fd00::/129".toCIDR()
}
}
@Test
fun `rejects a negative prefix`() {
assertThrows(IllegalArgumentException::class.java) {
"192.168.1.0/-1".toCIDR()
}
}
}
class RouteAddressTest {
@Test
fun `splits a mixed route list by address family`() {
val options = optionsWithRoutes(
listOf("192.168.1.0/24", "fd00::/8", "10.0.0.0/8", "2000::/3"),
)
val ipv4 = options.getIpv4RouteAddress()
val ipv6 = options.getIpv6RouteAddress()
assertEquals(2, ipv4.size)
assertEquals(2, ipv6.size)
assertTrue(ipv4.all { it.address is Inet4Address })
assertTrue(ipv6.all { it.address is Inet6Address })
}
@Test
fun `returns empty lists for an empty route list`() {
val options = optionsWithRoutes(emptyList())
assertTrue(options.getIpv4RouteAddress().isEmpty())
assertTrue(options.getIpv6RouteAddress().isEmpty())
}
@Test
fun `preserves the configured order within a family`() {
val options = optionsWithRoutes(
listOf("10.0.0.0/8", "fd00::/8", "192.168.0.0/16"),
)
assertEquals(
listOf(8, 16),
options.getIpv4RouteAddress().map { it.prefixLength },
)
}
@Test
fun `propagates a malformed entry instead of silently dropping it`() {
val options = optionsWithRoutes(listOf("192.168.1.0/24", "not-a-cidr"))
assertThrows(IllegalArgumentException::class.java) {
options.getIpv4RouteAddress()
}
}
}
+491 -267
View File
File diff suppressed because it is too large Load Diff
+513 -289
View File
File diff suppressed because it is too large Load Diff
+522 -298
View File
File diff suppressed because it is too large Load Diff
+327 -103
View File
@@ -1,5 +1,6 @@
{
"rule": "规则",
"rules": "规则",
"global": "全局",
"direct": "直连",
"dashboard": "仪表盘",
@@ -8,16 +9,32 @@
"profiles": "配置",
"tools": "工具",
"logs": "日志",
"logsDesc": "日志捕获记录",
"resources": "资源",
"resourcesDesc": "外部资源相关信息",
"trafficUsage": "流量统计",
"networkSpeed": "网络速度",
"outboundMode": "出站模式",
"networkDetection": "网络检测",
"serviceStatus": "服务状态",
"serviceRestricted": "访问受限",
"serviceAvailable": "可用",
"serviceUnavailable": "不可用",
"serviceFailed": "检测失败",
"serviceDisallowedIsp": "不允许的 ISP",
"serviceBlocked": "已被封禁",
"serviceUnsupportedRegion": "地区不支持",
"serviceOriginalsOnly": "仅限自制内容",
"serviceComingSoon": "即将上线",
"servicePending": "待检测",
"serviceManage": "管理服务",
"serviceCheck": "检测",
"serviceCheckAll": "全部检测",
"serviceCheckedAt": "检测于 {time}",
"upload": "上传",
"download": "下载",
"nullProfileDesc": "没有配置文件,请先添加配置文件",
"usedTraffic": "已用流量",
"totalTraffic": "总流量",
"expireTime": "到期时间",
"nullProfileDesc": "添加一个配置文件后即可开始使用",
"settings": "设置",
"language": "语言",
"defaultText": "默认",
@@ -27,30 +44,27 @@
"en": "英语",
"ja": "日语",
"ru": "俄语",
"zh_CN": "中文简体",
"zhCN": "中文简体",
"theme": "主题",
"themeDesc": "设置深色模式,调整色彩",
"override": "覆写",
"allowLan": "局域网代理",
"allowLanDesc": "允许通过局域网访问代理",
"tun": "虚拟网卡",
"tunDesc": "仅在管理员模式生效",
"minimizeOnExit": "退出时最小化",
"minimizeOnExitDesc": "修改系统默认退出事件",
"autoLaunch": "自启动",
"autoLaunchDesc": "跟随系统自启动",
"silentLaunch": "静默启动",
"silentLaunchDesc": "后台启动",
"silentLaunchDesc": "启动时不显示窗口",
"autoRun": "自动运行",
"autoRunDesc": "应用打开时自动运行",
"logcat": "日志捕获",
"logcatDesc": "禁用将会隐藏日志入口",
"autoCheckUpdate": "自动检查更新",
"autoCheckUpdateDesc": "应用启动时自动检查更新",
"checkCertificate": "校验 TLS 证书",
"checkCertificateDesc": "拒绝不受信任的证书。关闭后订阅和备份将暴露于中间人攻击",
"accessControl": "访问控制",
"accessControlDesc": "配置应用访问代理",
"application": "应用程序",
"applicationDesc": "修改应用程序相关设置",
"edit": "编辑",
"confirm": "确定",
"update": "更新",
@@ -80,7 +94,7 @@
"dark": "深色",
"importFromURL": "从URL导入",
"submit": "提交",
"doYouWantToPass": "是否要通过",
"createProfileFromUrlTip": "是否要通过 {url} 创建配置?",
"create": "创建",
"pleaseUploadValidQrcode": "请上传有效的二维码",
"blacklistMode": "黑名单模式",
@@ -90,21 +104,26 @@
"appAccessControl": "应用访问控制",
"accessControlAllowDesc": "只允许选中应用进入VPN",
"accessControlNotAllowDesc": "选中应用将会被排除在VPN之外",
"accessControlDisabledDesc": "应用访问控制已关闭",
"selected": "已选择",
"proxyPort": "代理端口",
"port": "端口",
"logLevel": "日志等级",
"show": "显示",
"exit": "退出",
"systemProxy": "系统代理",
"safeMode": "安全模式",
"safeModeAppTitle": "{appName}(安全模式)",
"project": "项目",
"core": "内核",
"tabAnimation": "选项卡动画",
"desc": "基于ClashMeta的多平台代理客户端,简单易用,开源无广告。",
"startVpn": "正在启动VPN...",
"stopVpn": "正在停止VPN...",
"startVpn": "正在启动VPN…",
"stopVpn": "正在停止VPN…",
"localNetworkDeniedTip": "本地网络权限被拒绝:已改用 gvisor 栈,局域网无法访问。",
"compatible": "兼容模式",
"notSelectedTip": "当前代理组无法选中",
"changeProxyFailedTip": "切换代理失败,已恢复上一次的选择",
"databaseWriteFailedTip": "保存更改失败,已回滚",
"tip": "提示",
"account": "账号",
"backup": "备份",
@@ -118,6 +137,9 @@
"addressHelp": "WebDAV服务器地址",
"addressTip": "请输入有效的WebDAV地址",
"password": "密码",
"authentication": "认证",
"authenticationDesc": "为本地代理端口启用认证,防止本机其他应用擅自使用",
"authenticationSystemProxyDesc": "认证启用时不生效",
"checkUpdate": "检查更新",
"discoverNewVersion": "发现新版本",
"checkUpdateError": "当前应用已经是最新版了",
@@ -126,36 +148,36 @@
"country": "区域",
"search": "搜索",
"allowBypass": "允许应用绕过VPN",
"allowBypassDesc": "开启后部分应用可绕过VPN",
"externalController": "外部控制器",
"externalControllerDesc": "开启后将可以通过9090端口控制Clash内核",
"ipv6Desc": "开启后将可以接收IPv6流量",
"app": "应用",
"general": "常规",
"systemProxyDesc": "设置系统代理",
"startupAndBackground": "启动与后台",
"requestsAndUpdates": "请求与更新",
"logsAndDiagnostics": "日志与诊断",
"userAgent": "用户代理",
"unifiedDelay": "统一延迟",
"unifiedDelayDesc": "去除握手等额外延迟",
"tcpConcurrent": "TCP并发",
"tcpConcurrentDesc": "开启后允许TCP并发",
"geodataLoader": "Geo低内存模式",
"geodataLoaderDesc": "开启将使用Geo低内存加载器",
"requests": "请求",
"requestsDesc": "查看最近请求记录",
"recentRequests": "最近请求",
"findProcessMode": "查找进程",
"init": "初始化",
"infiniteTime": "长期有效",
"connections": "连接",
"connectionsDesc": "查看当前连接数据",
"liveConnections": "实时连接",
"inbound": "入站",
"intranetIP": "内网 IP",
"view": "查看",
"cut": "剪切",
"copy": "复制",
"clipboardWriteFailed": "无法复制到剪贴板,所选内容可能过大",
"paste": "粘贴",
"testUrl": "测速链接",
"sync": "同步",
"exclude": "从最近任务中隐藏",
"excludeDesc": "应用在后台时,从最近任务中隐藏应用",
"excludeDesc": "应用在后台时,从最近任务中隐藏应用",
"expand": "标准",
"shrink": "紧凑",
"min": "最小",
@@ -170,17 +192,18 @@
"go": "前往",
"externalLink": "外部链接",
"otherContributors": "其他贡献者",
"appIconDesign": "应用图标设计",
"autoCloseConnections": "自动关闭连接",
"autoCloseConnectionsDesc": "切换节点后自动关闭连接",
"onlyStatisticsProxy": "仅统计代理",
"onlyStatisticsProxyDesc": "开启后,将只统计代理流量",
"onlyStatisticsProxy": "仅统计代理流量",
"showNotificationStopAction": "通知栏显示停止按钮",
"pureBlackMode": "纯黑模式",
"sidebarBlur": "侧边栏背景模糊",
"sidebarBlurDesc": "侧边栏透出窗口后方模糊的桌面",
"keepAliveIntervalDesc": "TCP保持活动间隔",
"entries": "个条目",
"local": "本地",
"remote": "远程",
"remoteBackupDesc": "备份数据到WebDAV",
"localBackupDesc": "备份数据到本地",
"mode": "模式",
"time": "时间",
"source": "来源",
@@ -194,89 +217,156 @@
"loose": "宽松",
"profilesSort": "配置排序",
"start": "启动",
"runTime": "启动时间",
"stop": "暂停",
"dnsDesc": "更新DNS相关设置",
"key": "键",
"value": "值",
"hostsDesc": "追加Hosts",
"vpnTip": "重启VPN后改变生效",
"vpnEnableDesc": "通过VpnService自动路由系统所有流量",
"options": "选项",
"loopback": "回环解锁工具",
"loopbackDesc": "用于UWP回环解锁",
"providers": "提供者",
"enabled": "已开启",
"disabled": "已关闭",
"loopback": "UWP 回环解锁",
"providers": "外部资源",
"proxyProviders": "代理集",
"ruleProviders": "规则集",
"behavior": "行为",
"format": "格式",
"providerUrlTip": "仅支持远程资源",
"subscriptionInfo": "订阅信息",
"overrideDns": "覆写DNS",
"overrideDnsDesc": "开启后将覆盖配置中的DNS选项",
"overrideNtp": "覆写NTP",
"ntpStatusDesc": "从NTP服务器获取时间,而非系统时钟",
"server": "服务器",
"ntpInterval": "同步间隔(分钟)",
"dialerProxy": "拨号代理",
"dialerProxyDesc": "用于连接NTP服务器的出站",
"writeToSystem": "写入系统",
"writeToSystemDesc": "同时设置系统时钟,Android上不生效",
"overrideEntries": "覆写项",
"addOverrideEntry": "添加覆写项",
"discardChanges": "是否放弃更改?",
"quickEdit": "快速编辑",
"status": "状态",
"statusDesc": "关闭后将使用系统DNS",
"preferH3Desc": "优先使用DOH的http/3",
"respectRules": "遵守规则",
"respectRulesDesc": "DNS连接跟随rules,需配置proxy-server-nameserver",
"respectRulesDesc": "DNS连接遵守规则,需配置Proxy Server Nameserver",
"dnsMode": "DNS模式",
"fakeipRange": "Fakeip范围",
"fakeipFilter": "Fakeip过滤",
"defaultNameserver": "默认域名服务器",
"defaultNameserverDesc": "用于解析DNS服务器",
"nameserver": "域名服务器",
"nameserverDesc": "用于解析域名",
"fakeipRange": "Fake-IP范围",
"fakeipFilter": "Fake-IP过滤",
"useHosts": "使用Hosts",
"useSystemHosts": "使用系统Hosts",
"nameserverPolicy": "域名服务器策略",
"nameserverPolicyDesc": "指定对应域名服务器策略",
"proxyNameserver": "代理域名服务器",
"proxyNameserverDesc": "用于解析代理节点的域名",
"fallback": "Fallback",
"fallbackDesc": "一般情况下使用境外DNS",
"fallbackFilter": "Fallback过滤",
"geoipCode": "Geoip代码",
"ipcidr": "IP/掩码",
"domain": "域名",
"listenRoutingMark": "监听路由标记",
"listenRoutingMarkDesc": "仅Linux有效",
"ipv6Timeout": "IPv6超时(毫秒)",
"cacheAlgorithm": "缓存算法",
"cacheMaxSize": "缓存大小",
"fakeipRange6": "Fake-IP范围(IPv6)",
"fakeipFilterMode": "Fake-IP过滤模式",
"fakeipFilterModeDesc": "blacklist排除匹配项,whitelist仅用于匹配项,rule按规则匹配",
"fakeipTtl": "Fake-IP TTL",
"reset": "重置",
"action_view": "显示/隐藏",
"action_start": "启动/停止",
"action_mode": "切换模式",
"action_proxy": "系统代理",
"action_tun": "虚拟网卡",
"actionView": "显示/隐藏",
"actionStart": "启动/停止",
"actionMode": "切换模式",
"actionProxy": "系统代理",
"actionTun": "虚拟网卡",
"actionRuleMode": "规则模式",
"actionGlobalMode": "全局模式",
"actionDirectMode": "直连模式",
"actionDelayTest": "测试全部延迟",
"actionUpdateProfiles": "更新全部配置",
"disclaimer": "免责声明",
"disclaimerDesc": "本软件仅供学习交流、科研等非商业性质的用途,严禁将本软件用于商业目的。如有任何商业行为,均与本软件无关。",
"disclaimerDesc": "在使用 FlClash(以下简称“本软件”)前,请仔细阅读并充分理解本声明的全部内容。点击“同意”即表示你已阅读、理解并接受以下全部条款;如不同意,请点击“退出”并停止使用本软件。",
"disclaimerSoftwareTitle": "软件性质",
"disclaimerSoftwareContent": "本软件是基于 Clash.Meta(mihomo)内核的开源网络代理客户端,仅提供配置管理、规则分流与流量转发等本地工具功能。\n\n本软件自身不提供任何代理服务器、节点、订阅、机场或网络接入服务,也不与任何此类服务的提供者存在合作、代理或担保关系。",
"disclaimerUsageTitle": "使用限制",
"disclaimerUsageContent": "本软件仅供学习交流、技术研究等非商业用途。严禁将本软件用于任何商业目的,包括但不限于付费分发、捆绑销售、作为商业服务的组成部分或以本软件名义开展经营活动;任何商业行为均与本软件及其开发者无关。\n\n严禁将本软件用于违反你所在国家或地区法律法规的活动,包括但不限于绕过依法设置的网络访问限制、传播违法信息、实施网络攻击或侵犯他人合法权益。",
"disclaimerResponsibilityTitle": "用户责任",
"disclaimerResponsibilityContent": "你应自行确认使用本软件在你所在国家或地区是合法的,并对使用本软件的全部行为及其后果独立承担法律责任。\n\n你导入的订阅、节点与配置由你自行选择和判断,其来源是否合法、内容是否安全、服务是否稳定,均由你与相关提供者自行负责。",
"disclaimerThirdPartyTitle": "第三方内容",
"disclaimerThirdPartyContent": "订阅链接、配置文件、规则集、脚本、外部资源及外部链接均由第三方提供,开发者无法也不会对其合法性、准确性、安全性和可用性进行审查或担保。\n\n因使用第三方内容导致的数据泄露、财产损失、账号封禁或其他任何损失,由你与第三方自行解决,开发者不承担任何责任。",
"disclaimerWarrantyTitle": "无担保声明",
"disclaimerWarrantyContent": "本软件按“现状”和“可用”的原则提供,不附带任何明示或默示的担保,包括但不限于适销性、特定用途适用性、不侵权以及持续可用、无错误、无安全漏洞的担保。\n\n开发者不保证本软件能满足你的需求,也不保证本软件的运行不会中断或不会出现错误。",
"disclaimerLiabilityTitle": "责任限制",
"disclaimerLiabilityContent": "在适用法律允许的最大范围内,开发者及所有贡献者均不对因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊、惩罚性或后果性损害承担责任,包括但不限于数据丢失、设备损坏、网络异常、业务中断、利润损失或因此引起的任何法律纠纷,即使已被告知发生此类损害的可能性。",
"disclaimerPrivacyTitle": "数据收集与隐私",
"disclaimerPrivacyContent": "本软件不会收集或上传你的订阅地址、节点信息、配置内容、访问的网站、连接记录、流量内容和日志,这些数据仅保存在你的设备本地,开发者无法获取。\n\n本软件只会在你使用相应功能时访问外部网络,例如更新订阅时访问你提供的订阅地址、检查更新时访问 GitHub。\n\n桌面版(Windows、macOS、Linux)未集成任何数据统计或崩溃上报服务。Android 版集成了 Google Firebase 的以下两项服务,用于改进应用稳定性:",
"disclaimerCrashlyticsTitle": "Firebase Crashlytics(崩溃分析)",
"disclaimerCrashlyticsContent": "应用发生崩溃时,自动上传崩溃报告。\n\n收集内容:崩溃堆栈与错误信息、崩溃发生时间、应用版本与构建号、设备品牌与型号、Android 系统版本、屏幕方向、剩余内存与存储空间、设备是否已 root,以及一个随安装生成、重装后即重置的随机安装标识。\n\n用途:仅用于定位和修复崩溃问题。\n\n你可以在“工具 > 常规 > 崩溃分析”中随时关闭。",
"disclaimerAnalyticsTitle": "Firebase Analytics(使用统计)",
"disclaimerAnalyticsContent": "随 Firebase 自动收集基础的应用使用统计。\n\n收集内容:首次启动、应用打开与会话时长、应用更新等基础事件,应用实例标识,设备型号、系统版本、系统语言,以及由 IP 地址推断的国家或地区级大致位置。\n\n用途:仅用于了解活跃设备数、版本分布与系统兼容情况。开发者不会将这些数据用于广告,不会出售,也不会与你的订阅或配置相关联。",
"disclaimerDataProcessingContent": "上述数据由 Google 代为处理和存储,可能被传输至你所在国家或地区以外(如美国)的服务器,并受 Google 隐私政策与 Firebase 隐私和安全说明约束。崩溃报告最多保留 90 天,统计数据按 Firebase 的默认策略保留。",
"disclaimerLicenseTitle": "开源协议",
"disclaimerLicenseContent": "本软件以 GPL-3.0 协议开源。你可以在遵守该协议的前提下自由使用、修改和分发本软件,但须同样以 GPL-3.0 协议开源并保留原作者的版权声明。\n\n本软件包含的第三方组件(包括 Clash.Meta 内核)分别遵循其各自的开源协议。基于本软件修改或分发的版本所产生的任何问题,与原作者无关。",
"disclaimerChangesTitle": "声明变更",
"disclaimerChangesContent": "开发者有权随版本更新修改本声明,修改后的内容随新版本发布即生效。你在更新后继续使用本软件,即视为接受修改后的声明。",
"disclaimerAcceptTitle": "接受声明",
"disclaimerAcceptContent": "一旦你安装、复制或使用本软件,即视为你已阅读并同意本声明的全部内容。如你不同意本声明的任何条款,请立即停止使用并卸载本软件。",
"disclaimerAndroidOnly": "仅 Android",
"disclaimerGooglePrivacy": "Google 隐私政策",
"disclaimerFirebasePrivacy": "Firebase 隐私和安全说明",
"agree": "同意",
"hotkeyManagement": "快捷键管理",
"hotkeyManagementDesc": "使用键盘控制应用程序",
"pressKeyboard": "请按下按键",
"inputCorrectHotkey": "请输入正确的快捷键",
"hotkeyConflict": "快捷键冲突",
"hotkeyDesc": "全局快捷键在窗口隐藏时依然生效,点击操作即可录制组合键。",
"hotkeyNotSet": "未设置",
"pressKeyboard": "请按下组合键",
"hotkeyNeedsModifier": "至少包含 {modifiers} 中的一个",
"hotkeyConflictWith": "已被“{action}”使用,保存后将移到这里",
"hotkeyUnavailable": "未能注册,可能已被其他应用占用",
"remove": "移除",
"noHotKey": "暂无快捷键",
"noNetwork": "无网络",
"ipv6InboundDesc": "允许IPv6入站",
"exportLogs": "导出日志",
"exportSuccess": "导出成功",
"iconStyle": "图标样式",
"onlyIcon": "仅图标",
"iconStyleFilled": "色块",
"iconStylePlain": "纯图标",
"iconStyleHidden": "隐藏",
"filter": "筛选",
"hideTimeoutProxies": "隐藏超时节点",
"hideTimeoutProxiesDesc": "不显示上次延迟测试超时的节点",
"stackMode": "栈模式",
"network": "网络",
"networkDesc": "修改网络相关设置",
"bypassDomain": "排除域名",
"bypassDomainDesc": "仅在系统代理启用时生效",
"resetTip": "确定要重置吗?",
"resetTip": "确定要重置吗?",
"icon": "图片",
"noData": "暂无数据",
"fontFamily": "字体",
"toggle": "切换",
"system": "系统",
"routeMode": "路由模式",
"routeMode_bypassPrivate": "绕过私有路由地址",
"routeMode_config": "使用配置",
"routeModeBypassPrivate": "绕过私有路由地址",
"routeModeConfig": "使用配置",
"routeAddress": "路由地址",
"routeAddressDesc": "配置监听路由地址",
"pleaseInputAdminPassword": "请输入管理员密码",
"interfaceNameMode": "出站网卡",
"interfaceNameModeClear": "清空",
"interfaceNameModeFollow": "跟随配置",
"interfaceNameModeCustom": "自定义",
"interfaceName": "网卡名称",
"interfaceNameDesc": "出站连接使用的网卡名称",
"copyEnvVar": "复制环境变量",
"memoryInfo": "内存信息",
"total": "总计",
"releaseMemory": "释放内存",
"memoryCoreHeapInuse": "堆内存使用中",
"memoryCoreHeapIdle": "堆内存空闲",
"memoryCoreStack": "协程栈",
"memoryCoreRuntime": "运行时开销",
"memoryCoreNotRunning": "内核未运行",
"memoryAppResident": "常驻内存",
"memoryAppShared": "应用及共享",
"memoryEstimateDesc": "基于进程常驻内存估算,可能与系统显示的数值不同。",
"memoryEstimateSharedDesc": "内核与应用运行在同一进程,内核部分按运行时统计估算,其余计入应用及共享内存。",
"memoryReleased": "内存已释放",
"memoryReleasedSize": "已释放 {size}",
"releaseMemoryFailed": "释放内存失败",
"cancel": "取消",
"fileIsUpdate": "文件有修改,是否保存修改",
"profileHasUpdate": "配置文件已经修改,是否关闭自动更新 ",
"profileHasUpdate": "配置文件已经修改,是否关闭自动更新?",
"hasCacheChange": "是否缓存修改",
"copySuccess": "复制成功",
"copyLink": "复制链接",
@@ -286,22 +376,27 @@
"listen": "监听",
"undo": "撤销",
"redo": "重做",
"lineWrap": "自动换行",
"none": "无",
"basicConfig": "基本配置",
"basicConfigDesc": "全局修改基本配置",
"advancedConfig": "进阶配置",
"advancedConfigDesc": "提供多样化配置",
"advancedConfigDesc": "网络、DNS、附加规则与脚本",
"selectedCountTitle": "已选择 {count} 项",
"addRule": "添加规则",
"ruleName": "规则名称",
"content": "内容",
"subRule": "子规则",
"ruleTarget": "规则目标",
"matchTarget": "MATCH-TARGET",
"followProfile": "跟随配置",
"sourceIp": "源IP",
"noResolve": "不解析IP",
"saveChanges": "是否保存更改?",
"findProcessModeDesc": "开启后会有一定性能损耗",
"tabAnimationDesc": "仅在移动视图中有效",
"navigationBarStyle": "底栏",
"floating": "悬浮",
"docked": "固定",
"slide": "滑动",
"fade": "淡入",
"pureBlack": "纯黑",
"colorSchemes": "配色方案",
"palette": "调色板",
"tonalSpotScheme": "调性点缀",
@@ -319,27 +414,38 @@
"messageTestTip": "这是一条消息。",
"crashTest": "崩溃测试",
"crashDetected": "检测到崩溃",
"crashDetectedTip": "检测到应用上次运行发生崩溃。为避免重复崩溃,已清除当前配置选择,并跳过本次自动配置。",
"crashDetectedTip": "应用连续两次未能完成启动。为打断崩溃循环,已取消选中配置 {name},并跳过本次自动配置,你可以随时重新选中它。",
"launchInterrupted": "启动未完成",
"launchInterruptedTip": "应用上次在启动过程中意外退出。已跳过本次自动配置,你可以手动启动重试。",
"clearData": "清除数据",
"textScale": "文本缩放",
"textScalePreview": "应用内的文字将以这个大小显示",
"followSystem": "跟随系统",
"internet": "互联网",
"systemApp": "系统应用",
"noNetworkApp": "无网络应用",
"restoreStrategy": "恢复策略",
"restoreStrategy_override": "覆盖",
"restoreStrategy_compatible": "兼容",
"restoreStrategyOverride": "覆盖",
"restoreStrategyCompatible": "兼容",
"logsTest": "日志测试",
"emptyTip": "{label}不能为空",
"urlTip": "{label}必须为URL",
"numberTip": "{label}必须为数字",
"interval": "间隔",
"existsTip": "{label}当前已存在",
"maxLengthTip": "{label}最多{max}个字符",
"singleAdd": "单条添加",
"batchAdd": "批量添加",
"batchListInputTip": "每行一项,也可用逗号分隔",
"batchMapInputTip": "每行一条,键和值之间用空格分隔",
"batchPreviewTip": "将添加 {count} 项,跳过 {skipped} 项已存在",
"lineIssueTip": "第 {line} 行:{message}",
"singleValueTip": "{label}只能是一项",
"deleteTip": "确定删除当前{label}吗?",
"deleteMultipTip": "确定删除选中的{label}吗?",
"nullTip": "暂无{label}",
"script": "脚本",
"color": "颜色",
"rename": "重命名",
"unnamed": "未命名",
"pleaseEnterScriptName": "请输入脚本名称",
"mixedPort": "混合端口",
@@ -351,6 +457,7 @@
"import": "导入",
"importFile": "通过文件导入",
"importUrl": "通过URL导入",
"startFromScratch": "从零开始",
"autoSetSystemDns": "自动设置系统DNS",
"details": "{label}详情",
"creationTime": "创建时间",
@@ -369,7 +476,7 @@
"request": "请求",
"connected": "已连接",
"disconnected": "已断开",
"connecting": "连接中...",
"connecting": "连接中…",
"restartCoreTip": "您确定要重启核心吗?",
"forceRestartCoreTip": "您确定要强制重启核心吗?",
"dnsHijacking": "DNS劫持",
@@ -379,21 +486,25 @@
"crashlytics": "崩溃分析",
"crashlyticsTip": "开启后,应用崩溃时自动上传不包含敏感信息的崩溃日志",
"appendSystemDns": "追加系统DNS",
"appendSystemDnsTip": "强制为配置附加系统DNS",
"editRule": "编辑规则",
"overrideMode": "覆写模式",
"standardModeDesc": "标准模式,覆写基本配置,提供简单追加规则能力",
"standardModeDesc": "标准模式,覆写基础配置,提供简单追加规则能力",
"scriptModeDesc": "脚本模式,使用外部扩展脚本,提供一键覆写配置的能力",
"addedRules": "附加规则",
"controlGlobalAddedRules": "控制全局附加规则",
"quickAdd": "快捷添加",
"rulePresetBlockQuic": "屏蔽 QUIC",
"rulePresetBlockStun": "屏蔽 STUN",
"rulePresetBlockDot": "屏蔽 DNS over TLS",
"rulePresetSystemServicesDirect": "Apple 与 Microsoft 直连",
"rulePresetBittorrentDirect": "BT 下载直连",
"rulePresetLanDirect": "局域网直连",
"overrideScript": "覆写脚本",
"goToConfigureScript": "前往配置脚本",
"editGlobalRules": "编辑全局规则",
"externalFetch": "外部获取",
"confirmForceCrashCore": "确定要强制崩溃核心?",
"confirmClearAllData": "确定要清除所有数据?",
"loading": "加载中...",
"loadTest": "加载测试",
"loading": "加载中…",
"yearsAgo": "{count} 年前",
"monthsAgo": "{count} 个月前",
"daysAgo": "{count} 天前",
@@ -409,26 +520,33 @@
"speedStatistics": "网速统计",
"resetPageChangesTip": "当前页面存在更改,确定重置吗?",
"overwriteTypeCustom": "自定义",
"overwriteTypeCustomDesc": "自定义模式,支持完全自定义修改代理组以及规则",
"overwriteTypeCustomDesc": "自定义模式,支持完全自定义修改代理、策略组以及规则",
"unknownNetworkError": "未知网络错误",
"restoreException": "恢复异常",
"networkException": "网络异常,请检查连接后重试",
"backupFromNewerVersion": "该备份来自更高版本的应用,请先更新应用再恢复",
"networkTimeoutError": "请求超时,请检查网络或代理后重试",
"networkHostLookupError": "无法解析服务器地址,请确认链接正确且 DNS 可用",
"networkConnectionError": "无法连接到服务器,请检查网络连接或代理设置",
"networkTlsError": "安全连接失败,服务器证书可能无效,或连接被拦截",
"networkCancelledError": "请求已取消",
"networkAccessDeniedError": "服务器拒绝访问(HTTP {code}),链接可能已过期或凭据有误",
"networkNotFoundError": "该地址下没有内容(HTTP {code}),请确认链接是否正确",
"networkRateLimitedError": "请求过于频繁(HTTP 429),请稍后再试",
"networkServerError": "服务器出现问题(HTTP {code}),请稍后再试",
"networkBadResponseError": "服务器拒绝了请求(HTTP {code})",
"networkRequestFailed": "网络请求失败:{detail}",
"invalidBackupFile": "无效备份文件",
"pruneCache": "修剪缓存",
"backupAndRestore": "备份与恢复",
"backupAndRestoreDesc": "通过WebDAV或者文件同步数据",
"restore": "恢复",
"restoreSuccess": "恢复成功",
"restoreFromWebDAVDesc": "通过WebDAV恢复数据",
"restoreFromFileDesc": "通过文件恢复数据",
"restoreOnlyConfig": "仅恢复配置文件",
"restoreAllData": "恢复所有数据",
"addProfile": "添加配置",
"delayTest": "延迟测试",
"proxyGroupEmpty": "策略组为空",
"proxyGroupNameEmpty": "策略组名称不能为空",
"proxyGroupNameDuplicate": "策略组名称重复",
"confirmExitWindow": "确定要退出当前窗口吗?",
"confirmExitWindow": "确定要退出当前窗口吗?",
"dataChangedSave": "检测到数据有更改,是否保存",
"selectProxyProviders": "选择代理集",
"proxyFilter": "节点过滤器",
@@ -438,9 +556,13 @@
"excludeProxyFilter": "排除节点过滤器",
"excludeType": "排除类型",
"expectedStatus": "预期状态",
"strategy": "策略",
"tolerance": "容差",
"selectProxies": "选择代理",
"inputProxyGroupName": "输入策略组名称",
"helperCorruptTip": "Helper 服务不可用,无法启用 TUN 模式,请重新安装 FlClash。",
"coreBlockedByPolicyTip": "Windows 拒绝运行 FlClashCore.exe(错误 {code})。智能应用控制、AppLocker 等应用控制策略会拦截未签名程序,请在该策略中放行 FlClash 或关闭策略后重试。",
"coreBlockedBySmartAppControlTip": "Windows 智能应用控制拦截了未签名的 FlClashCore.exe。请打开 Windows 安全中心 → 应用和浏览器控制 → 智能应用控制设置,选择「关闭」后重新启动 FlClash。智能应用控制关闭后无法再开启,除非重装 Windows。",
"hideFromList": "从列表中隐藏",
"testWhenUsed": "使用时测试",
"disableUDP": "禁用UDP",
@@ -460,7 +582,7 @@
"basicStrategy": "基础策略",
"editProxy": "编辑代理",
"includeAllProxyProviders": "包含所有代理集",
"includeAllProxyProvidersTip": "开启后将覆盖引入的代理集",
"includeAllProxyProvidersTip": "开启后包含本配置的全部代理集:订阅自带的,以及任一策略组引用的配置和应用代理集",
"addProxyProviders": "添加代理集",
"includeAllProxies": "包含所有代理",
"includeAllProxiesTip": "引入不包含策略组的所有代理,可在下方额外添加策略组",
@@ -478,10 +600,11 @@
"ruleActionDomainDesc": "匹配完整域名",
"ruleActionDomainSuffixDesc": "匹配域名后缀",
"ruleActionDomainKeywordDesc": "匹配域名关键字",
"ruleActionDomainRegexDesc": "通配符匹配,仅支持*和?通配符",
"ruleActionDomainRegexDesc": "使用域名正则表达式匹配",
"ruleActionDomainWildcardDesc": "通配符匹配,仅支持*和?通配符",
"ruleActionGeositeDesc": "匹配 Geosite 内的域名",
"ruleActionIpCidrDesc": "匹配 IP 地址范围",
"ruleActionIpCidr6Desc": "匹配 IP 地址范围, IP-CIDR6 只是一个别名",
"ruleActionIpCidr6Desc": "匹配 IP 地址范围,IP-CIDR6 只是一个别名",
"ruleActionIpSuffixDesc": "匹配 IP 后缀范围",
"ruleActionIpAsnDesc": "匹配 IP 所属 ASN",
"ruleActionGeoipDesc": "匹配 IP 所属国家代码",
@@ -495,18 +618,21 @@
"ruleActionInTypeDesc": "匹配入站类型",
"ruleActionInUserDesc": "匹配入站用户名,支持使用 / 分隔多个用户名",
"ruleActionInNameDesc": "匹配入站名称",
"ruleActionRematchNameDesc": "匹配重匹配名称,多个名称用/分隔",
"ruleActionProcessPathDesc": "使用完整进程路径匹配",
"ruleActionProcessPathRegexDesc": "使用进程路径正则表达式匹配",
"ruleActionProcessPathWildcardDesc": "使用进程路径通配符匹配,仅支持*和?通配符",
"ruleActionProcessNameDesc": "使用进程匹配,在Android平台可以匹配包名",
"ruleActionProcessNameRegexDesc": "使用进程名称正则表达式匹配,在Android平台可以匹配包名",
"ruleActionProcessNameWildcardDesc": "使用进程名称通配符匹配,仅支持*和?通配符",
"ruleActionUidDesc": "匹配 Linux USER ID",
"ruleActionNetworkDesc": "匹配TCP或者UDP",
"ruleActionDscpDesc": "匹配DSCP标记 (仅限 tproxy udp 入站)",
"ruleActionDscpDesc": "匹配DSCP标记(仅限 tproxy udp 入站)",
"ruleActionRuleSetDesc": "引用规则集合,需配置rule-providers",
"ruleActionAndDesc": "逻辑规则 AND",
"ruleActionOrDesc": "逻辑规则 OR",
"ruleActionNotDesc": "逻辑规则 NOT",
"ruleActionSubRuleDesc": "匹配至子规则,需要注意括号的使用",
"ruleActionSubRuleDesc": "匹配至子规则,需要注意括号的使用",
"ruleActionMatchDesc": "匹配所有请求,无需条件",
"subRuleEmpty": "子规则为空",
"proxyProvidersNotEmpty": "代理集不能为空",
@@ -518,19 +644,19 @@
"invalidSubRule": "{subRule} 是一个无效的SUB_RULE",
"invalidPolicy": "{target} 是一个无效的策略",
"invalidProxyProvider": "{providerName} 是一个无效的代理集",
"invalidRuleSet": "{ruleSet} 不是有效的规则集",
"invalidNetworkContent": "仅支持 tcp 或 udp",
"invalidRangeContent": "请输入数字或范围,如 80 或 8000-9000,多个用 / 分隔",
"invalidDscpContent": "DSCP 标记不能超过 63",
"invalidProxy": "{proxyName} 是一个无效的代理",
"proxyGroupDetectedAbnormal": "检测到当前策略组异常",
"proxyProviderDetectedAbnormal": "检测到选中的代理集存在异常",
"proxyDetectedAbnormal": "检测到选中的代理存在异常",
"createProfile": "创建配置",
"locationPermissionRequired": "需要位置权限",
"locationPermissionGuide": "1. 打开 系统设置 > 隐私与安全性\n2. 选择 定位服务\n3. 在右侧列表中找到并勾选 {appName}\n\n完成设置后,返回应用即可正常使用。感谢您的配合。",
"prerequisites": "前置条件",
"ignoreBatteryOptimization": "忽略电池优化",
"batteryOptimizationDesc": "为保证后台运行,请关闭本应用的电池优化。点击前往设置。",
"batteryOptimizationStatusTip": "受系统影响,不代表一定准确",
"batteryOptimizationStatusTip": "由于系统限制,运行状态下无法正确获取电池优化状态",
"locationPermission": "位置权限",
"locationPermissionDesc": "根据系统要求,获取Wi-Fi名称需要您授予位置权限。",
"locationPermissionDesc": "根据系统要求,获取Wi-Fi名称需要您授予位置权限。Android 上请选择“始终允许”,否则应用在后台时无法获取 Wi-Fi 名称。",
"excludeSsids": "排除SSIDs",
"excludeSsidsDesc": "连接到被排除SSID的WIFI时,将会自动切换应用运行状态",
"ssidsEmpty": "SSIDs为空",
@@ -541,7 +667,7 @@
"editSsid": "编辑SSID",
"authorized": "已授权",
"tapToAuthorize": "点击授权",
"suspended": "挂起中...",
"suspended": "挂起中…",
"geoOptions": "Geo 选项",
"geoAutoUpdate": "自动更新",
"geoAutoUpdateInterval": "自动更新间隔",
@@ -549,9 +675,107 @@
"hours": "小时",
"hoursCount": "{count} 小时",
"geoResources": "Geo 资源",
"geoUpdating": "正在更新 {name}...",
"geoSkipped": "{name} 已是最新版本",
"geoUpdated": "{name} 已更新",
"secondsCount": "{count} 秒",
"entriesCount": "{count} 个条目"
"proxiesCount": "{count} 个代理",
"rulesCount": "{count} 条规则",
"changelogBreaking": "重大变更",
"changelogFeatures": "新功能",
"changelogFixes": "问题修复",
"changelogPerformance": "性能优化",
"changelogReverts": "已回滚",
"close": "关闭",
"back": "返回",
"minimize": "最小化",
"maximize": "最大化",
"unmaximize": "向下还原",
"pinWindow": "窗口置顶",
"unpinWindow": "取消置顶",
"exitFullScreen": "退出全屏",
"torch": "手电筒",
"pickFromAlbum": "从相册选择",
"blockConnection": "阻止连接",
"closeConnections": "关闭连接",
"scrollToSelected": "滚动到已选",
"showMore": "展开",
"showLess": "收起",
"previousMatch": "上一个匹配",
"nextMatch": "下一个匹配",
"clearSearch": "清除搜索",
"noSearchResults": "没有匹配的结果",
"addWidget": "添加组件",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"authorize": "授权",
"installedAppsPermissionRequired": "需要读取应用列表权限",
"installedAppsPermissionDesc": "当前系统在授权前不会提供已安装的应用列表,授权后即可配置分应用代理。",
"installedAppsPermissionDeniedMessage": "读取应用列表权限已被拒绝,无法获取已安装的应用。请前往系统设置手动开启。",
"outboundIp": "出站 IP",
"ipAddress": "IP 地址",
"ipQualityLevel": "等级",
"ipQualityGood": "优",
"ipQualityNormal": "普通",
"ipQualityRisky": "风险",
"ipType": "类型",
"ipTypeResidential": "住宅",
"ipTypeMobile": "移动网络",
"ipTypeBusiness": "商业",
"ipTypeHosting": "机房",
"ipFlags": "命中标记",
"ipFlagProxy": "代理",
"ipFlagVpn": "VPN",
"ipFlagTor": "Tor",
"ipFlagAbuser": "滥用记录",
"ipOrganization": "组织",
"ipAsn": "ASN",
"ipQualitySource": "采用来源",
"ipQualitySources": "各来源",
"ipQualityFailed": "类型查询失败",
"ipQualityRetry": "重新查询",
"ipSourceNoType": "无法判定类型",
"ipSourceRateLimited": "限流",
"ipSourceIpMismatch": "出站 IP 不一致",
"hideIp": "隐藏 IP",
"editorUnavailable": "编辑器不可用",
"replace": "替换",
"replaceAll": "全部替换",
"cameraPermissionRequired": "需要相机权限",
"cameraPermissionDesc": "请在系统设置中允许访问相机以扫描二维码,或从相册选择二维码图片。",
"cameraUnavailable": "相机不可用",
"retry": "重试",
"invalidProfileQrcode": "该二维码不包含配置文件链接",
"dnsQueries": "DNS查询",
"recordType": "记录类型",
"answers": "应答",
"responseCode": "响应码",
"error": "错误",
"initiator": "发起方",
"cache": "缓存",
"proxyNode": "代理节点",
"yes": "是",
"no": "否",
"nonTextProviderFile": "该外部资源不是文本文件",
"addCustomProxy": "添加代理",
"proxyDefinition": "完整配置",
"proxyDefinitionNotMap": "配置必须是包含 name 和 type 的 YAML 映射",
"customProxiesEmpty": "未添加自定义代理,沿用配置文件自身的代理",
"overwriteIssueEmptyName": "名称为空",
"overwriteIssueReservedName": "{name} 是内置策略名,不能在此使用",
"overwriteIssueDuplicateName": "名称 {name} 已被其他代理或策略组使用",
"overwriteIssueCoreRejected": "内核无法解析该代理:{message}",
"overwriteIssueMissingProxies": "以下代理或策略不存在:{names}",
"overwriteIssueMissingProviders": "以下代理集不存在:{names}",
"overwriteIssueNoProxySource": "未选择任何代理或代理集,内核会拒绝该策略组",
"overwriteIssueGroupLoop": "策略组之间存在循环引用:{path}",
"overwriteIssuesSummary": "{count} 项存在问题,应用覆写可能失败",
"overwriteIssueSubscriptionGroupMissingProxies": "配置文件自身的策略组引用了自定义代理中已没有的代理:{names}",
"providerInUse": "{label} 仍被 {profiles} 的自定义策略组或规则使用,请先在那里移除",
"providerSourceSubscription": "订阅",
"providerRenameShadowed": "{profiles} 的订阅里已有 {label},改名后这些配置会改用订阅里的那个,请换一个名称",
"fontSize": "大小",
"large": "大",
"extraLarge": "超大",
"switchProfile": "切换配置",
"lastUpdated": "上次更新"
}
Binary file not shown.
-23
View File
@@ -1,23 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="75" y="50" width="150" height="180" rx="24" fill="#FDF7FF" stroke="#E8DEF8" stroke-width="2"/>
<rect x="95" y="90" width="36" height="12" rx="4" fill="#E8DEF8"/>
<rect x="140" y="90" width="65" height="12" rx="6" fill="#E8DEF8"/>
<path d="M95 118H205" stroke="#E8DEF8" stroke-width="2" stroke-dasharray="4 4"/>
<rect x="95" y="138" width="40" height="12" rx="6" fill="#E8DEF8" opacity="0.7"/>
<rect x="145" y="138" width="50" height="12" rx="6" fill="#E8DEF8" opacity="0.5"/>
<rect x="95" y="162" width="55" height="12" rx="6" fill="#E8DEF8" opacity="0.7"/>
<rect x="160" y="162" width="30" height="12" rx="6" fill="#E8DEF8" opacity="0.5"/>
<g transform="translate(210, 210)">
<circle cx="0" cy="0" r="38" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
<path d="M-10 16V-16M-10 -16L-18 -8M-10 -16L-2 -8" stroke="#FDF7FF" stroke-width="5" stroke-linecap="round"
stroke-linejoin="round"/>
<path d="M10 -16V16M10 16L2 8M10 16L18 8" stroke="#FDF7FF" stroke-width="5" stroke-linecap="round"
stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Some files were not shown because too many files have changed in this diff Show More