Compare commits

..

169 Commits

Author SHA1 Message Date
chen08209 a8655b0809 chore: bump version to 0.9.1+2026101701 2026-10-17 09:55:19 +08:00
chen08209 6c52d7a39a fix(ui): stop non-secret fields from opening a password keyboard
Changelog: Fix pasting and Chinese input in text fields
2026-10-17 09:55:10 +08:00
chen08209 4d68938d22 docs(agents): document surfaces, typography and the lib/ui layer 2026-10-17 09:54:29 +08:00
chen08209 213a13f62f feat(l10n): add French, Spanish and Korean
Changelog: French, Spanish and Korean translations
2026-10-17 09:54:29 +08:00
chen08209 fefbcf4598 feat(android): drop Firebase analytics and crash reporting
Changelog: No more Firebase analytics or crash reporting
2026-10-17 09:54:29 +08:00
chen08209 9f48781341 feat(network)!: set desktop TUN key by key over each profile's own
BREAKING CHANGE: Desktop TUN keys follow the profile unless picked; changed values carry over
Changelog: Per-key desktop TUN settings; system proxy and tray fixes
2026-10-17 09:54:29 +08:00
chen08209 dd952dd600 feat(theme): redesign theme colors with an iOS-style picker
Changelog: Redesigned theme colors and color styles
2026-10-17 09:54:29 +08:00
chen08209 bdbf4ab024 feat(dashboard): redesign the dashboard around the current exit
Changelog: A redesigned dashboard around the current exit
2026-10-17 09:54:29 +08:00
chen08209 347a74b940 feat(profiles): manage external resources from the profile details
Changelog: Profile-detail resources and include-all in custom groups
2026-10-17 09:54:29 +08:00
chen08209 eb47ee261f feat(proxies): show groups as cards and group tabs as chips
Changelog: Proxy groups as cards, group tabs as chips
2026-10-17 09:54:29 +08:00
chen08209 e82ebf785b feat(views): show connections, requests and DNS as tables
Changelog: Sortable tables for connections, requests and DNS
2026-10-17 09:54:29 +08:00
chen08209 ba15c313b4 feat(ui): add Liquid Glass and slide-to-pick popup menus
Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
2026-10-17 09:54:29 +08:00
chen08209 568c49bb3a feat(ui): layer surfaces with fills and follow the iOS type scale
Changelog: iOS-style surfaces, alerts and sheets; clearer TV focus
2026-10-16 22:54:53 +08:00
chen08209 2201c199b8 perf(android): keep Core calls and events off the main thread
Changelog: Smoother Android UI while the proxy is busy
2026-10-16 22:54:53 +08:00
chen08209 5984c161c5 fix(core): keep very large profiles from crashing the app
Changelog: Large profiles no longer crash; downloads trust core CAs
2026-10-16 22:54:53 +08:00
chen08209 a5b6d300f6 test: run the suite from a few shared entrypoints 2026-10-16 22:54:53 +08:00
chen08209 03f9553824 chore(release): v0.9.0 2026-10-14 09:16:30 +08:00
chen08209 8d93268de5 docs(agents): document custom profiles and setting changes 2026-10-12 10:34:14 +08:00
chen08209 491fae10f5 feat(views)!: set DNS, NTP, sniffer and TUN key by key
BREAKING CHANGE: DNS and NTP override toggles are removed; picked keys apply to every profile
Changelog: Per-key DNS, NTP, sniffer and TUN settings
2026-10-12 10:34:14 +08:00
chen08209 3abda78af4 feat(profiles): add a custom profile editor
Changelog: Custom profiles
2026-10-12 10:17:54 +08:00
chen08209 8c43053951 feat(ui): stack bottom sheets and add context menus
Changelog: Stacked sheets, context menus and a sort mode
2026-10-12 10:17:54 +08:00
chen08209 532ebf07e9 feat(providers): assemble custom profiles and import configs
Changelog: DNS, Hosts and ports apply without a restart
2026-10-11 12:28:52 +08:00
chen08209 16770249f1 feat(database)!: store custom profiles and their settings
BREAKING CHANGE: Custom overwrites are removed; rebuild them as custom profiles
Changelog: Unreadable settings no longer block startup or restores
Changelog-Type: fix
2026-10-11 12:28:52 +08:00
chen08209 d3fa2252b0 feat(android): add launcher shortcuts and one-step on-demand access
Changelog: Launcher shortcuts and one-step on-demand access
2026-10-11 12:28:52 +08:00
chen08209 94a25f4827 fix(desktop): fix the system proxy, tray and Helper on Windows and Linux
Changelog: Windows and Linux desktop fixes
2026-10-11 12:28:52 +08:00
chen08209 2fa2f7f816 feat(core): bind the Android Core with gomobile
Changelog: Share link import and mrs rule sets
2026-10-11 12:28:52 +08:00
chen08209 6c661f662e build: move to Flutter 3.47.6, Go 1.27 and Rust 1.99.0 2026-10-11 12:28:52 +08:00
chen08209 d780cb863d fix(core,helper): only let the owner drive the elevated Core
Changelog: The elevated Core only answers its owner
2026-10-11 12:28:52 +08:00
chen08209 4b59eca853 docs(site): drive the scroll effects from script and drop the trailing slash from links
CSS scroll-driven animations made touch scrolling drop frames in Android
WebView on a 120 Hz phone, so the progress bar and the preview tilt now
follow the scroll position from script. The release pulse animates a
transform instead of `left` and `right`, which ran layout on every frame.

GitHub Pages redirects the slashless project URL, so the links still resolve.
2026-10-03 20:54:21 +08:00
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
chen08209 e2f678909d Optimize commented policy 2026-08-17 14:50:35 +08:00
chen08209 7fb4f4f711 Fix whole group delay test failing on Windows 2026-08-17 14:49:53 +08:00
chen08209 903e2b8879 Optimize package icon loading and connections polling 2026-08-17 14:49:53 +08:00
chen08209 2df430ca31 Update changelog 2026-08-14 07:06:49 +00:00
chen08209 aee7ee2643 Optimize core service 2026-08-14 14:36:25 +08:00
chen08209 9a99897864 Optimize Android TV launcher icon 2026-08-14 14:35:10 +08:00
chen08209 4551e57ff4 Optimize back navigation 2026-08-14 14:35:09 +08:00
chen08209 1cffeaa29e Optimize more details 2026-08-14 14:35:09 +08:00
chen08209 5f2c12642b Fix some issues 2026-08-14 14:35:09 +08:00
chen08209 c493df386d Optimize app layout 2026-08-14 14:35:09 +08:00
chen08209 425d32b50d Optimize focus control 2026-08-14 14:35:09 +08:00
chen08209 51e24ef9de Adjust android process 2026-08-14 14:35:08 +08:00
chen08209 7c831855ef Update changelog 2026-07-11 12:35:22 +00:00
chen08209 7e7f1f89f3 Fix macos performance issue
Support custom global-ua

Update core

Optimize some details
2026-07-11 20:13:36 +08:00
BIGGASSS 5179d55dfb Fix linux silent launching not working 2026-07-11 19:12:15 +08:00
chen08209 ac2f6b919e Update changelog 2026-05-29 06:55:29 +00:00
chen08209 45015f856b Support custom overwrite
Support run on demand

Optimize windows ipc

Optimize windows arm64

Optimize build

Optimize some details

Update core
2026-05-29 14:35:56 +08:00
chen08209 672eaccd35 Update changelog 2026-02-02 02:38:00 +00:00
chen08209 2fbb96f5c1 Add sqlite store
Optimize android quick action

Optimize backup and restore

Optimize more details
2026-02-02 10:15:42 +08:00
chen08209 243b3037d9 Update changelog 2025-12-12 06:52:14 +00:00
chen08209 6e404ab19c Fix windows some issues
Optimize overwrite handle

Optimize access control page

Optimize some details
2025-12-12 14:33:03 +08:00
chen08209 2395a4b20c Update changelog 2025-10-08 08:41:35 +00:00
chen08209 d3c3f04062 Fix android tile service
Support append system DNS

Fix some issues
2025-10-08 16:28:02 +08:00
chen08209 201062dc5d Update changelog 2025-09-27 07:31:55 +00:00
chen08209 45b163184d Fix some issues
Optimize Windows service mode

Update core
2025-09-27 15:17:19 +08:00
chen08209 2ab70f193a Update changelog 2025-09-23 07:44:50 +00:00
chen08209 ed7868282a Add android separates the core process
Support core status check and force restart

Optimize proxies page and access page

Update flutter and pub dependencies

Update go version

Optimize more details
2025-09-23 15:23:58 +08:00
chen08209 e956373ef4 Update changelog 2025-07-29 02:57:43 +00:00
chen08209 1154e7b245 Optimize desktop view
Optimize logs, requests, connection pages

Optimize windows tray auto hide

Optimize some details

Update core
2025-07-29 10:43:05 +08:00
chen08209 adb890d763 Update changelog 2025-06-15 10:59:15 +00:00
chen08209 1477f9bd9c Fix windows tun issues
Optimize android get system dns

Optimize more details
2025-06-15 18:44:19 +08:00
chen08209 a06e813249 Update changelog 2025-06-07 16:08:38 +00:00
chen08209 afbc5adb05 Support override script
Support proxies search

Support svg display

Optimize config persistence

Add some scenes auto close connections

Update core

Optimize more details
2025-06-07 23:52:27 +08:00
chen08209 76c9f08d4a Fix issues that TUN repeat failed to open. 2025-05-01 22:12:05 +08:00
chen08209 f83a8e0cce Update changelog 2025-05-01 13:03:36 +00:00
chen08209 f5544f1af7 Fix windows service verify issues 2025-05-01 20:45:23 +08:00
chen08209 eeb543780a Update changelog 2025-04-30 16:20:40 +00:00
chen08209 676f2d058a Add windows server mode start process verify
Add linux deb dependencies

Add backup recovery strategy select

Support custom text scaling

Optimize the display of different text scale

Optimize windows setup experience

Optimize startTun performance

Optimize android tv experience

Optimize default option

Optimize computed text size

Optimize hyperOS freeform window

Add developer mode

Update core

Optimize more details
2025-05-01 00:02:29 +08:00
chen08209 fb9d0cb22c Add issues template 2025-04-19 22:57:07 +08:00
chen08209 e7eb312254 Update changelog 2025-04-18 09:09:33 +00:00
chen08209 c9cd80bcb3 Optimize android vpn performance
Add custom primary color and color scheme

Add linux nad windows arm release

Optimize requests and logs page
2025-04-18 16:54:05 +08:00
chen08209 a77b3a35e8 Fix map input page delete issues 2025-04-13 17:32:01 +08:00
chen08209 2d2708d7bd Update changelog 2025-04-08 07:55:45 +00:00
chen08209 ef5f6dbd59 Add rule override
Update core

Optimize more details
2025-04-08 15:35:14 +08:00
chen08209 b6c7b15e3e Update changelog 2025-03-10 10:53:24 +00:00
chen08209 de9c5ba9cc Optimize dashboard performance
Fix some issues
2025-03-10 18:41:42 +08:00
chen08209 2aae00cf68 Fix unselected proxy group delay issues 2025-03-10 18:41:42 +08:00
chen08209 68be2d34a1 Fix asn url issues 2025-03-08 04:22:32 +08:00
chen08209 7895ccf720 Update changelog 2025-03-07 16:03:59 +00:00
chen08209 e92900dbbd Fix tab delay view issues
Fix tray action issues

Fix get profile redirect client ua issues

Fix proxy card delay view issues

Add Russian, Japanese adaptation

Fix some issues
2025-03-07 23:49:27 +08:00
chen08209 eada271c49 Update changelog 2025-03-05 07:22:18 +00:00
chen08209 5dda2854be Fix list form input view issues
Fix traffic view issues
2025-03-05 15:11:19 +08:00
chen08209 5184ed6fc7 Update changelog 2025-03-05 02:36:31 +00:00
chen08209 4e679f776e Optimize performance
Update core

Optimize core stability

Fix linux tun authority check error

Fix some issues
2025-03-05 10:21:51 +08:00
chen08209 96328f66e9 Fix scroll physics error 2025-02-09 16:51:57 +08:00
chen08209 3eb14ab8a1 Update changelog 2025-02-09 08:36:14 +00:00
chen08209 c6266b7917 Add windows storage corruption detection
Fix core crash caused by windows resource manager restart

Optimize logs, requests, access to pages

Fix macos bypass domain issues
2025-02-09 16:23:40 +08:00
chen08209 6c27f2e2f1 Update changelog 2025-02-03 13:28:20 +00:00
chen08209 e04a0094b1 Fix some issues 2025-02-03 21:15:26 +08:00
chen08209 683e6a58ea Update changelog 2025-02-02 11:48:19 +00:00
chen08209 b340feeb49 Update popup menu
Add file editor

Fix android service issues

Optimize desktop background performance

Optimize android main process performance

Optimize delay test

Optimize vpn protect
2025-02-02 19:34:42 +08:00
chen08209 6a39b7ef5a Update changelog 2025-01-10 11:22:18 +00:00
chen08209 35f89fea90 Update core
Fix some issues
2025-01-10 19:08:15 +08:00
chen08209 58acd9c1ab Update changelog 2025-01-09 02:24:30 +00:00
chen08209 ef97ef40a1 Remake dashboard
Optimize theme

Optimize more details

Update flutter version
2025-01-09 10:10:06 +08:00
chen08209 9cb75f4814 Update changelog 2024-12-08 16:50:30 +00:00
chen08209 375c4e0884 Support better window position memory
Add windows arm64 and linux arm64 build script

Optimize some details
2024-12-09 00:37:27 +08:00
chen08209 ece8a48181 Remake desktop
Optimize change proxy

Optimize network check

Fix fallback issues

Optimize lots of details
2024-12-06 20:38:51 +08:00
chen08209 4b32a096dd Update change.yaml 2024-12-06 19:24:11 +08:00
chen08209 04d6a928eb Fix android tile issues 2024-12-06 19:24:11 +08:00
chen08209 22e71ec6e1 Fix windows tray issues
Support setting bypassDomain

Update flutter version

Fix android service issues

Fix macos dock exit button issues

Add route address setting

Optimize provider view
2024-12-06 19:24:11 +08:00
chen08209 6f1b07e26b Update changelog 2024-12-06 19:24:11 +08:00
chen08209 1e3ab1c717 Update CHANGELOG.md 2024-12-06 19:24:11 +08:00
chen08209 3a43dc2fe9 Add android shortcuts
Fix init params issues

Fix dynamic color issues

Optimize navigator animate

Optimize window init

Optimize fab

Optimize save
2024-12-06 19:24:11 +08:00
chen08209 c94f64cf78 Fix the collapse issues
Add fontFamily options
2024-12-06 19:24:11 +08:00
chen08209 25764fc4d3 Update core version
Update flutter version

Optimize ip check

Optimize url-test
2024-12-06 19:24:11 +08:00
chen08209 649dbb9886 Update release message
Init auto gen changelog
2024-12-06 19:24:11 +08:00
chen08209 e1dd616d74 Fix windows tray issues
Fix urltest issues

Add auto changelog
2024-12-06 19:24:11 +08:00
chen08209 c91d78b63f Fix windows admin auto launch issues
Add android vpn options

Support proxies icon configuration

Optimize android immersion display

Fix some issues
2024-12-06 19:24:11 +08:00
chen08209 82767325e5 Optimize ip detection
Support android vpn ipv6 inbound switch

Support log export

Optimize more details
2024-12-06 19:24:11 +08:00
chen08209 3f0f7f051b Fix android system dns issues
Optimize dns default option

Fix some issues
2024-12-06 19:24:11 +08:00
chen08209 15c64327db Update readme 2024-12-06 19:24:11 +08:00
chen08209 09e35eb5dd Fix build error2 2024-12-06 19:24:11 +08:00
chen08209 77c4935106 Fix build error 2024-12-06 19:24:11 +08:00
chen08209 e6da643186 Support desktop hotkey
Support android ipv6 inbound

Support android system dns

fix some bugs
2024-12-06 19:24:11 +08:00
chen08209 61bd4e4549 Fix delete profile error 2024-12-06 19:24:11 +08:00
chen08209 a5f4d12748 Fix submit error 2 2024-12-06 19:24:11 +08:00
chen08209 9dc67f31a3 Fix submit error 2024-12-06 19:24:11 +08:00
chen08209 846ec9728f Optimize DNS strategy
Fix the problem that the tray is not displayed in some cases

Optimize tray

Update core

Fix some error
2024-12-06 19:24:11 +08:00
chen08209 6e5f1b8e5f Fix tun update issues 2024-12-06 19:24:11 +08:00
chen08209 aca4a3e979 Add DNS override
Fixed some bugs
Optimize more detail
2024-12-06 19:24:11 +08:00
chen08209 3783c3c650 Add Hosts override 2024-12-06 19:24:11 +08:00
chen08209 01aed99002 fix android tip error
fix windows auto launch error
2024-12-06 19:24:11 +08:00
chen08209 9aec1e75e4 Fix windows tray issues
Optimize windows logic
2024-12-06 19:24:11 +08:00
chen08209 5dfb95a22d Optimize app logic
Support windows administrator auto launch

Support android close vpn
2024-12-06 19:24:11 +08:00
chen08209 0f8cfa20b2 Change flutter version 2024-12-06 19:24:11 +08:00
chen08209 3933fd9d9e Support profiles sort
Support windows country flags display

Optimize proxies page and profiles page columns
2024-12-06 19:24:11 +08:00
chen08209 b7400f2ce8 Update flutter version 2024-12-06 19:24:11 +08:00
chen08209 d0ec580932 Update version 2024-12-06 19:24:11 +08:00
chen08209 6bc0be9876 Update timeout time 2024-12-06 19:24:11 +08:00
chen08209 38221bcd10 Update access control page
Fix bug
2024-12-06 19:24:11 +08:00
chen08209 bee2f8aa4f Optimize provider page
Optimize delay test

Support local backup and recovery
2024-12-06 19:24:11 +08:00
chen08209 6cfcaa4edc Fix android tile service issues 2024-12-06 19:24:11 +08:00
chen08209 3fdc69edd5 Fix linux core build error 2024-12-06 19:24:11 +08:00
chen08209 ed7ade0d72 Add proxy-only traffic statistics
Update core

Optimize more details
2024-12-06 19:24:11 +08:00
txyyh 621ddefc65 Add fdroid-repo 2024-12-06 19:24:11 +08:00
1521 changed files with 402990 additions and 92449 deletions
+32
View File
@@ -0,0 +1,32 @@
# Agent Configuration Model
Use the smallest durable surface that matches the job.
## Surfaces
- `AGENTS.md`: repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
- No `CLAUDE.md`: Claude Code v2.1.277+ loads `AGENTS.md` at startup only while no `CLAUDE.md`, `.claude/CLAUDE.md`, or `CLAUDE.local.md` sits in the working directory or above it, so adding one silently replaces `AGENTS.md` for Claude. Sessions that cannot load `AGENTS.md` directly (third-party providers, telemetry disabled) need an uncommitted `CLAUDE.local.md` containing `@AGENTS.md`. The one nested `CLAUDE.md`, in `plugins/rust_api/`, imports the root `AGENTS.md` for the same reason.
- `.agents/*.md`: human- and agent-readable reference docs linked from `AGENTS.md`. Use these for detailed project context, commands, architecture, and conventions.
- `.agents/skills/*/SKILL.md`: repo skills shared by Codex and Claude Code. Use these for repeatable workflows that should be discoverable by name and description; `.claude/skills/` holds one symlink per skill so Claude Code finds the same files.
- `.codex/config.toml`: trusted project Codex settings such as MCP, hooks, sandbox, approval, or model defaults.
- `.codex/rules/*.rules`: command permission rules for running commands outside the sandbox. None exist yet. Do not mix these with coding conventions in `.agents/rules.md`.
- `.codex/hooks.json` or `.codex/config.toml` hooks: lifecycle automation such as prompt checks, command validation, or final verification gates.
## Placement Rules
- Put stable team conventions in `AGENTS.md` only when every task must see them without opening another file.
- Put detailed explanations in `.agents/*.md` and link them from `AGENTS.md`.
- Put reusable task workflows in `.agents/skills/<skill-name>/SKILL.md`.
- Put mechanical enforcement in linters, tests, hooks, or `.codex/rules`; do not rely on prose when tooling can enforce the rule.
- Split a rule into the part that can be counted and the part that cannot, and enforce them differently. A rule resting entirely on judgment gets decided at the keyboard, at the moment the exception looks most justified; the comment policy is the worked example, where "is this comment useful" stays prose and review while "are these comments too frequent" became a measured gate. Find the countable half before writing more prose.
- Restate the one-line form of a rule in every context that must obey it, and keep its explanation in one `.agents/*.md` file that the others link to. Prefer a runnable check to either: a gate an agent trips before reporting done outperforms a paragraph asking it to have been careful.
- State a gate's real force. Enforcement differs per tool, and a rule that claims uniform coverage it does not have is worse than one that names the gap.
- Keep user-specific preferences out of the repository. They belong in user-level Codex configuration or user-level skills.
## Skill Authoring Rules
- Use lowercase hyphenated names.
- Start descriptions with `Use when...`.
- Describe trigger conditions in the description, not the workflow.
- Keep `SKILL.md` lean; link to `.agents/*.md` for large reference material.
- Add scripts only when deterministic behavior is needed repeatedly.
File diff suppressed because it is too large Load Diff
+388
View File
@@ -0,0 +1,388 @@
# Commands
## Building
Update submodules first. The ClashMeta Go core lives in `core/Clash.Meta/`.
```bash
git submodule update --init --recursive
```
Full package build, including Go core, Flutter, and packaging, runs through `setup.dart`:
```bash
dart setup.dart macos
dart setup.dart linux
dart setup.dart windows
dart setup.dart android
```
The desktop Go core and the Rust helper build automatically: Flutter runs
`plugins/setup/hook/build.dart` on every `flutter build` and `flutter test`,
and the hook drives `CoreBuilder` from the `setup_hooks` package in
`plugins/setup/setup_hooks/`. Artifacts land in `libclash/`. Android's Core is
the AAR the `:core` Gradle module builds with `gomobile bind` before anything
compiles against it; that needs `go` on `PATH` and nothing else, since the
gomobile tools come from `core/go.mod` and the NDK from AGP. The hook reruns only when Go, Rust, or
`setup_hooks` inputs change; the fingerprint cache lives in
`.dart_tool/setup_build_cache/`. To force a rebuild, delete that directory:
```bash
rm -rf .dart_tool/setup_build_cache
```
Flutter hides the hook's output on success, so to see why the Core was or was
not rebuilt read `.dart_tool/setup_build_cache/hook.log`; each invocation starts
with a `===` line carrying its timestamp and target.
The hook runs for `flutter test` and for `dart run` of any root-package script
too, so a pure Dart test run needs the Go toolchain, and on Linux and Windows
also `cargo` and `rustc` — the Helper fingerprint shells out to both even on a
cache hit. The hook protocol carries no build mode and no caller, so the only
switch is the user-define in the root `pubspec.yaml`:
```yaml
hooks:
user_defines:
setup:
build_assets: true # false turns the Go core and Helper hook into a no-op
rust_api:
build_assets: true # false does the same for the Rust library
```
CI flips `setup` to `false` with `yq` in its throwaway checkout before
`dart run tool/changelog.dart` and `dart run tool/test.dart`; no test loads the Go core. `rust_api`
stays on there because the editor tests in `test/plugins/code_forge/` and the socket
test in `test/core/desktop/ipc_native_test.dart` load the Rust library the hook
builds; the release-notes job, which runs no tests, flips both.
Never commit `false`: a build with it set stages whatever `libclash/` already
holds and bundles no Rust library, which is why `setup.dart` refuses to package
while it is set. Locally, `false` is worth setting for a Dart-only test loop,
but the hook cache keys on the user-define, so the first build afterwards runs
the hooks again. The editor and IPC socket tests still need the Rust library: keep `rust_api`
on, or point `FRB_DART_LOAD_EXTERNAL_LIBRARY_NATIVE_LIB_DIR` at a directory that
holds a `rust_api` library built from the same sources.
## Flutter Development
Use the default Flutter SDK directly:
```bash
flutter pub get
flutter run
flutter test
```
Use `flutter test`, not `dart test`, because models pull in Flutter types.
A development build takes over the host exactly like a release: it creates the TUN device, sets the system proxy, on
macOS rewrites the system DNS, registers itself as the login item, and on Windows and Linux starts its Core through the
shared privileged Helper, which stops the Core an installed FlClash is running. Only the data directory is kept apart,
and only for some builds: a macOS debug build is `com.follow.clash.debug` and an Android debug build `com.follow.clash.dev`,
while profile and release builds, and every build on Windows and Linux, share the directory and preferences of the
installed app. Safe mode keeps all of the host changes off and never opens the Core's listeners or external
controller, so it does not compete with the installed app for the mixed, DNS, RESTful API and other loopback ports:
```bash
flutter run --dart-define-from-file=env.json --dart-define=SAFE_MODE=true
```
`.run/main_safe_mode.run.xml` is the same launch for IntelliJ, and `Flutter App (Safe Mode)` in `.zed/debug.json` (debugger) or `flutter run (Safe Mode)` in `.zed/tasks.json`
(plain run) for Zed. The build is bannered `SAFE MODE`, the tray shows the
`status_4` bug icon with a menu of only show and exit and no traffic title, and the settings keep their values, so
nothing has to be flipped back before a normal run. See the safe mode section of `architecture.md` for the owners that consult the flag.
## Code Generation
Run code generation after modifying models, providers, or database schema:
```bash
dart run build_runner build --delete-conflicting-outputs
dart run build_runner watch
```
Code generation covers:
- Riverpod providers through `riverpod_generator`.
- Models through `freezed` and `json_serializable`.
- Database tables through `drift_dev`.
The Dart-Rust bindings in `plugins/rust_api` are generated by `flutter_rust_bridge_codegen` from
`plugins/rust_api/flutter_rust_bridge.yaml`; rerun it from that directory after changing anything under
`rust/src/api/`:
```bash
cd plugins/rust_api && flutter_rust_bridge_codegen generate
```
Generated output paths, configured in `build.yaml`:
- `lib/models/generated/*.g.dart`, `*.freezed.dart`.
- `lib/providers/generated/*.g.dart`.
- `lib/database/generated/*.g.dart`.
Tray and Windows app icons are generated, not hand-edited. `assets_source/images/icon/*.svg` is
the source of truth; the script needs `rsvg-convert` (librsvg) on `PATH`:
```bash
dart run tool/generate_status_icons.dart
```
It writes the tray PNGs with Flutter `2.0x/`–`4.0x/` resolution variants to `assets/images/tray/unix/`,
multi-size tray `.ico` files to `assets/images/tray/windows/`, and `windows/runner/resources/app_icon.ico`
from `assets/images/icon.png`. macOS draws its menu bar icon as a template, so it skips the ringed color
icons, whose logo shrinks to a small glyph once only alpha shows: `status_1` renders the bare logo from
`assets_source/images/icon/glyph.svg` and `status_4` reuses `status_4.svg`, both recolored to black into
`assets/images/tray/macos/`; the sources keep their colors. `glyph.svg` is also the geometry of the Android notification drawables
`android/service/src/main/res/drawable/ic.xml` and `ic_service.xml`; change them together. `pubspec.yaml` declares the three tray directories with `platforms:` so each
build only bundles the format its tray loads; a new status icon needs a source SVG and an entry in the
script's `statusIconNames`, nothing in `pubspec.yaml`.
## Testing
Tests use `package:test/test.dart` for pure Dart logic and `flutter_test` for provider and widget tests. `mocktail` is the mocking framework.
```bash
flutter test test/models/
flutter test test/core/
flutter test test/core/desktop/
flutter test test/providers/
flutter test test/common/
flutter test test/database/
flutter test test/widgets/
flutter test test/setup_test.dart
flutter test plugins/proxy/test/proxy_test.dart
```
For the whole suite, or a directory of more than a few files, use `tool/test.dart`, which is also what CI runs:
```bash
dart run tool/test.dart # all of test/
dart run tool/test.dart test/widgets test/views # some paths
dart run tool/test.dart -- --reporter expanded --coverage
```
Plain `flutter test` compiles each file into its own program and starts an engine for it, one file after another, and
that dominates a full run: 329 files took 128 s on a 12-core Mac with at most four running at once. `tool/test.dart`
imports the files into 32 generated entrypoints under `.dart_tool/test_bundles/` and runs those with one worker per
core: about 60 s for the same run, and 67 s instead of 180–213 s with `--coverage`. Files in one entrypoint share an
isolate, so a test can see state a file before it left behind; the script runs every file that failed there again on
its own, lets that run decide, and names the files that only failed together. See the Shared Test Isolate section of
`rules.md` before fixing one.
`plugins/setup/setup_hooks/` is a pure Dart package and is tested with `dart test` from its own directory, which is
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
run of `plugins/setup` would execute the build hook itself.
Native plugin code under `plugins/*/{linux,macos,windows}` is compiled by the CI `desktop` job, which runs
`flutter build <platform> --debug` on every branch push, and by the release matrix on a tag; `tool/check_plugins.sh`
only analyzes and tests the Dart side.
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`.
For the current Core/service architecture, useful focused checks are:
```bash
flutter test test/core/desktop/
flutter test test/core/service_test.dart
flutter test test/core/protocol_contract_test.dart
flutter test test/manager/core_manager_test.dart
flutter test test/providers/action_test.dart test/providers/system_action_test.dart
flutter test test/widgets/core_status_button_test.dart
```
What those suites own:
- `test/core/desktop/`: replaceable IPC transport, RPC request correlation/failure, direct/Helper process leases, and
latest-intent desktop lifecycle convergence. `ipc_native_test.dart` runs the transport over a real socket through
the Rust `IpcServer`; the server's own behavior is covered by `cargo test` in `plugins/rust_api/rust`.
- `test/core/service_test.dart`: `CoreService` composition and terminal close behavior.
- `test/core/protocol_contract_test.dart`: shared Dart/Go method and event-envelope compatibility, including event batches.
- `test/providers/action_test.dart`: Core start/restart orchestration and overlapping restart requests.
- `test/providers/system_action_test.dart`: ordered, idempotent exit cleanup and watchdog behavior.
- `test/widgets/core_status_button_test.dart`: 600-millisecond connecting presentation hold, immediate failure display,
long-running connecting state, and disconnected restart.
## Native Component Verification
The CI Go-wrapper checks can be reproduced without CGO, the Android files included:
```bash
cd core
CGO_ENABLED=0 go test .
CGO_ENABLED=0 go vet . ./cmd/...
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go vet -tags=with_gvisor . ./mobile ./tun ./platform
```
The Windows Helper's loopback/session protocol tests are host-independent by default. Windows CI additionally enables its
service implementation:
```bash
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
cargo test --manifest-path services/helper/Cargo.toml
cargo test --manifest-path services/helper/Cargo.toml --features windows-service
```
The last command requires Windows for meaningful service coverage. Native Android lifecycle edits should at minimum
compile the modules they touch, with the JDK 17 that CI installs. On macOS the Homebrew `openjdk@17` keg is not
registered with `java_home`, so export it by prefix:
```bash
cd android
export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home" # elsewhere: any JDK 17
./gradlew :core:compileDebugKotlin -Ptarget-platform=android-arm64 # builds the gomobile AAR for one ABI
./gradlew :service:compileDebugKotlin
./gradlew :app:compileDebugKotlin
```
Always-on VPN entry, system VPN revoke, actual permission UI, and rapid device start/stop still require Android device or
emulator validation; Kotlin compilation cannot prove those system callbacks.
## Changelog And Release
The changelog is derived from Conventional Commits by `tool/changelog.dart` and written to two committed files:
`CHANGELOG.md` for readers and `changelog.json` for the renderers. See `.agents/rules.md` for the `Changelog:` trailers
that decide the wording.
The app ships no changelog of its own. `render release` appends the released version as JSON inside an HTML comment
(`<!-- flclash:changelog:json … -->`), so the release body GitHub already returns to `checkForUpdate` carries the
notes shown in the update dialog. `parseReleaseChangelog` reads that block and falls back to the English
bullets when a release predates it.
```bash
dart run tool/changelog.dart verify # what CI checks
dart run tool/changelog.dart release --version 0.8.96
dart run tool/changelog.dart build --unreleased # changelog.json only, includes untagged work
dart run tool/changelog.dart render release --out release.md
dart run tool/changelog.dart render telegram --out telegram.md
```
Releasing a stable version, in order:
```bash
tool/bump_version.sh all
dart run tool/changelog.dart release --version 0.8.96
git commit -am "chore(release): v0.8.96"
git tag v0.8.96
git push origin main && git push origin v0.8.96
```
Push the tag by name. Every release tag here is lightweight, and `--follow-tags` carries annotated tags only: it skips a
lightweight one silently, so the branch lands, the tag does not, and the release workflow never fires.
`tool/release.sh` drives both paths so the ordering below cannot be got wrong by hand. It resolves the version (bumping
the patch when pubspec still names an already tagged one), prints the notes the tag would ship, and only pushes with
`--push`:
```bash
tool/release.sh pre --dry-run # plan and notes, changes nothing
tool/release.sh pre --push # bump, tag vX.Y.Z-pre.N, push
tool/release.sh stable --push # changelog, chore(release) commit, tag, push
```
The release commit comes before the tag on purpose: the generated wording is reviewable in the diff before it ships, and
the tag is what `render release` reads. CI never writes back to the repository; it only runs `verify`. Wording in
`changelog.json` may be edited by hand as long as no derivable entry disappears and every entry still points at a commit
inside that version's range.
Entries at or below `v0.8.96` are frozen: they predate the pipeline, live under the `<!-- changelog:frozen -->` marker
in `CHANGELOG.md`, and are never regenerated.
`verify` compares a version only when its tag is reachable from `HEAD`, because that is the same scope the builder walks
(`git tag --merged`). A branch cut before the newest release cannot derive that version at all, so `verify` names it as
skipped and moves on instead of reporting drift that does not exist. Checking mere tag existence is what made every such
branch fail on an unrelated release.
Prerelease tags (`v0.8.96-pre.N`) skip the release commit, and CI renders their notes with `build --unreleased` for the
Telegram post. They publish no GitHub release, so the update dialog never sees them. `build --unreleased` reads the
version from `pubspec.yaml` rather than the tag, so the patch has to be bumped before the first `-pre.N` of a cycle:
while `v<pubspec version>` is still tagged it refuses to collect anything and the release job fails.
### Website
`site/` is the GitHub Pages site: a static page that renders `CHANGELOG.md` and the download links in the browser, and
works as is for local previews. `tool/build_site.sh [dir]` assembles it with the preview images, the app icon,
`CHANGELOG.md` read at the latest release tag, and a `release.json` of asset names, sizes and digests fetched through
`gh`. Without `gh` access the page falls back to the newest version in `CHANGELOG.md` and lists the files without sizes.
It then runs `tool/render_site.mjs` (Node), which writes the English page and a `zh/` copy for search engines: page
text, the first changelog releases, canonical and `hreflang` links, JSON-LD and `sitemap.xml`, all from the strings,
changelog parser and release markup in `site/assets/shared.js`, plus the GitHub star count from `release.json` so the
nav does not grow when the page loads. `SITE_URL` overrides the published URL these point at.
On the built site each URL keeps its language: the language button links to the other page and carries the section in
view, the chosen platform and the changelog search. Only the English root sends readers who chose or prefer Chinese on
to `zh/`, unless its URL carries `?lang=en`: the button on `zh/` links there with it, so getting back to English never
depends on browser storage. A project site cannot serve the `robots.txt` that would announce the sitemap, so submit
`sitemap.xml` in Google Search Console and Bing Webmaster Tools once.
```bash
tool/build_site.sh && python3 -m http.server -d build/site
```
The page's look is deliberate: low-saturation flat colour, no blur, glow, glass or soft shadow, and motion that follows
the logo's 30° lattice. `site/assets/field.js` draws the hero's capsules on that lattice with the glyph's own
proportions, and the slanted wipes, the dot-to-bar marks and the theme transition reuse the same angle. Text that sits
inside the hero has to be listed in `wireField` in `app.js`, or the capsules run behind it.
`.github/workflows/pages.yaml` deploys it after every successful stable tag build and on pushes to `main` that touch the
site, in the public repository only; its Pages source has to be set to GitHub Actions once. The file names come from the
release matrix in `build.yaml`, so a renamed artifact needs the same change in `buildsFor` in `site/assets/app.js`.
## Verify
In the public repository every branch push runs the `dart` job; in the private
`origin` every check job runs only when started by hand (`workflow_dispatch`).
The `dart` job performs these root-package checks in order:
```bash
bash tool/hooks/test.sh
flutter pub get
dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart
flutter analyze --no-fatal-infos
dart run tool/changelog.dart verify # main and tags only
flutter test --reporter expanded --coverage
dart run tool/check_coverage.dart coverage/lcov.info 75
```
`tool/hooks/` holds the scripts the git hooks run, and `tool/hooks/test.sh` runs every `*_test.sh` beside them, so a
new hook script needs only its test file next to it, not a workflow step.
Run `flutter analyze` before committing; CI fails on anything it reports.
Release builds run only for `v*` tag pushes; pull requests trigger nothing.
Root analysis excludes `plugins/**`, and root tests do not discover nested
plugin packages, so parallel jobs validate the rest from their own package
directories: `plugins` (local Flutter packages and the setup build tool), `go`
(the Core wrapper, plus a vet of the Android files), `android` (JVM unit tests
for `:common`, `:service` and `:app`, with the Flutter compile tasks excluded
and the gomobile AAR built for one ABI), `rust` (both crates), and a
Windows runner for the helper's `windows-service` feature. On branch pushes a
`desktop` job also compiles a debug build per desktop platform; a tag skips it,
since its release matrix compiles the same native code. Release builds run only
in the public repository and start alongside the checks; `upload` publishes
only once every build and every check job passes.
`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script.
It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
`flutter test` wherever `test/*_test.dart` exists. Adding a plugin package needs
no workflow edit; enumerating packages by hand in the workflow is what
previously left `plugins/tray` unanalyzed and untested.
### CI Caches
A run restores only cache entries saved on its own branch or tag, or on the default branch (`main`), and the
repository holds 10 GB before evicting the least recently used entries. Every release is a new tag that no later run
reads from, so `BRANCH_CACHE` turns caching off in tag runs: one release used to write about 9 GB of such entries and
evict everything `dev` had saved. Release builds therefore start cold; branch runs, chiefly `dev`, keep the caches.
- setup-go keys its entry on the `cache-dependency-path` files alone and saves only on a miss, so jobs listing the
same files share one entry holding the build cache of whichever finished first. `desktop` and `android` each add
the file that drives their Core build to get an entry of their own.
- setup-gradle writes only on the default branch unless told otherwise, so `android` lets `dev` write as well. Its
entries are keyed by job, which is one more reason the release build gains nothing from caching.
## Worktree Tooling
```bash
bash tool/worktrees.sh list # every worktree with owner tool and dirty/clean state
bash tool/worktrees.sh prune # remove clean worktrees; add --force to drop dirty ones too
```
+103
View File
@@ -0,0 +1,103 @@
# Flutter Upgrade Checklist
Read this before changing `FLUTTER_VERSION` in `.github/workflows/build.yaml` or `.fvmrc`. Each entry is a
workaround tied to a Flutter bug: what it does, where it lives, and what has to be true before it can go. Remove an
entry together with its workaround; add one whenever a new workaround is pinned to the SDK.
## Stable cadence
Flutter ships a stable release about once a quarter, three minor versions apart (3.41 in February, 3.44 in May, 3.47
in August 2026). The two minors in between (3.48, 3.49) are beta only, so a fix that lands on `main` reaches stable
in the next multiple, 3.50, expected around mid-November 2026. Hotfix releases (3.47.x) carry only cherry-picks
listed in the SDK's `CHANGELOG.md`.
## Impeller disabled on Intel macOS release builds
- **Bug:** [flutter/flutter#191538](https://github.com/flutter/flutter/issues/191538). From 3.47, when Impeller
became the default desktop renderer, Intel (x86_64) Macs flicker across the whole window while scrolling or
animating, and text can render garbled. Apple Silicon is not affected.
- **Fix upstream:** landed on `main` before 2026-08-25 and verified on `3.48.0-1.0.pre-404`; it is not in any
3.47.x hotfix (checked through 3.47.6).
- **Workaround:** the release job for `macos-15-intel` (`arch: amd64`) runs
`plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist` before `dart setup.dart`, so only the Intel
DMG falls back to Skia. The checked-in `Info.plist` does not set the key, so local builds and the arm64 DMG keep
Impeller. The macOS embedder reads the key at launch in `FlutterDartProject.mm` (`enableImpeller`), release
builds included.
- **Side effect:** Skia on Intel renders blur, shadows and text slightly differently; judge Intel-only visual reports
against Skia, not Impeller.
- **Remove when:** `FLUTTER_VERSION` moves to a stable that contains the fix (3.50 or later, or a 3.47.x whose
changelog lists #191538). Before dropping the step, build the Intel DMG without it and scroll the proxies and
dashboard pages on an Intel Mac with its integrated GPU driving the built-in display; an external display can
switch to the discrete GPU and hide the flicker.
## Wrong-size back buffer on macOS
- **Bug:** [flutter/flutter#185394](https://github.com/flutter/flutter/issues/185394). `removeSurfaceForSize:` in
the macOS engine's `FlutterSurfaceManager.mm` hands out the youngest idle cached surface without checking its size.
When the window flips between sizes while the window server still holds the previous surface, which dragging a
window edge back and forth does, a request for one size gets a surface of another. Impeller's Metal texture then
disagrees with its descriptor, the render target drops it, and `Canvas::SetupRenderPass` dereferences a null
texture on the raster thread. Debug builds log `The texture and its descriptor disagree about its size` and
`Store action needs resolve but no valid resolve texture specified` first; release builds just crash.
- **Fix upstream:** [flutter/flutter#192522](https://github.com/flutter/flutter/pull/192522), still open; 3.47.6
ships the same `FlutterSurfaceManager.mm` as 3.47.4.
- **Workaround:** `BackBufferCacheGuard` in the `window` plugin's
`macos/window/Sources/window/BackBufferCacheGuard.swift`, installed from `WindowPlugin.register`, replaces
`-[FlutterBackBufferCache removeSurfaceForSize:]` at runtime and answers nil when the cached surface is not the
requested size, so the engine allocates a new one. It does nothing if the engine no longer has that class or
selector.
- **Remove when:** `FLUTTER_VERSION` moves to a stable that contains #192522 (check the SDK `CHANGELOG.md` for a
3.47.x cherry-pick). Before dropping it, run a debug build without the guard on that SDK and drag a window edge
back and forth for a minute; neither validation line may appear.
## Windows view visibility forwarded by the runner
- **Bug:** the Windows embedder's `WindowsLifecycleManager` counts the Flutter view as a window of its own and keeps
it visible until the view is sized to zero. Hiding the top-level window never resizes the view, and the `window`
plugin keeps it sized while minimized, so the app was never told `hidden`: animations kept rendering in the tray
and `appVisibleProvider` stayed true.
- **Workaround:** `WindowPluginForwardVisibility` in the `window` plugin's
`windows/include/window/window_plugin_runner.h` hands the top-level window's `WM_SHOWWINDOW` and `WM_SIZE` to
`ProcessExternalWindowMessage` for the view's HWND, so the view shows and hides with its window.
`FlutterWindow::MessageHandler` in `windows/runner/flutter_window.cpp` calls it before `HandleTopLevelWindowProc`;
the Flutter runner template has no such call, so keep it when regenerating the runner.
- **Remove when:** the engine hides the view together with its top-level window. Check with the proxy running and
the window focused: hiding it to the tray and minimizing it must each log `AppLifecycleState.hidden`.
## macOS activation resumes a hidden window
- **Bug:** `handleWillBecomeActive` in the macOS engine's `FlutterEngine.mm` resumes when any window in
`NSApp.windows` is visible, and the tray's `NSStatusBarWindow` always is. Activating the app while its window was
minimized or hidden, such as Cmd-Tab back to a minimized window, reported `resumed`, and the next deactivation
`inactive`, so frames ran until the window was shown again.
- **Workaround:** the `window` plugin observes will-become-active and will-resign-active and, once the engine has
answered, sends `AppLifecycleState.hidden` on `flutter/lifecycle` while its window is not visible
(`applicationActivityChanged` in `WindowPlugin.swift`). A window brought back without another activation change
would then wait on an occlusion notification macOS does not always post, keeping its last frame, so `show` and a
deminiaturize hand the state back (`windowBecameVisible`).
- **Remove when:** the engine ignores status item windows on activation. Check without the workaround: minimize the
window, switch to another app, then Cmd-Tab back without restoring it; the log must stay at
`AppLifecycleState.hidden`.
## Linux minimize on Wayland reads as inactive
- **Bug:** the Linux engine reports `hidden` for `GDK_WINDOW_STATE_ICONIFIED`, but xdg-shell has no minimized state,
so GTK 3 never sets it on Wayland. A minimized window reported only its focus loss, as `inactive`, and kept
rendering at the display rate (GNOME 46).
- **Workaround:** the `window` plugin's Linux `minimize` records the time, and a focus loss within 500 ms of it sends
`AppLifecycleState.hidden`. Its `window-state-event` handler is connected after the engine's, so that message
follows the engine's `inactive`; the compositor focuses the window when it restores it, which the engine reports as
`resumed`. A minimize the compositor starts itself (Super+H, the dock) is not covered.
- **Remove when:** the Linux engine reports minimized windows on Wayland, which needs GTK 4 or xdg-shell's
`suspended` state. Check in a GNOME Wayland session without the workaround: minimizing from the caption button
must log `AppLifecycleState.hidden`.
## Liquid glass saves around the transform of a blurring layer
- **Bug:** not filed upstream. On 3.47 Impeller scales a `saveLayer`'s image filter by a transform set inside the
layer that is still in effect when the layer is restored. A host's capture shrinks each pane's slot inside the
layer that blurs it, so its blur vanished at a quarter scale and spread half again as far at 1.5.
- **Workaround:** `_LiquidGlassHostLayer._snapshot` sets each slot's transform inside a `save` of its own, and each
band's offset, restored before the layer is.
- **Remove when:** without that `save`, the frosted-pane case of `test/widgets/liquid_glass_host_test.dart` passes
under `flutter test --enable-impeller`.
+104
View File
@@ -0,0 +1,104 @@
# Project Context
FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with Flutter. It supports Android, Windows, macOS, and Linux, using Material You from `material_ui` as the base with Apple's Human Interface interaction conventions layered on top.
## Version Notes
- Flutter stays on the 3.47.x line. Release CI pins 3.47.6 and is the source
of truth for release builds; `.fvmrc` and local SDKs may sit on another 3.47
patch. The editor in `plugins/code_forge` came from `code_forge` 10.14.0, which
needs Dart 3.13.2, so the CI pin cannot go below Flutter 3.47.2.
- Before moving the pin, walk `flutter-upgrade.md`: it lists the workarounds tied to
the current SDK, such as Impeller disabled on the Intel macOS release build.
- The Dart SDK lower bound in `pubspec.yaml` sets the package language
version. `flutter analyze` excludes `lib/**/generated/**`, so a raise is
proven by `flutter test`, which parses the generated code, not by analyze.
## Forked Dependencies
One `pubspec.yaml` dependency is pinned to a fork by commit SHA. The fork lives
under `chen08209`, the same account that owns this repository, so it is
maintained in-house rather than tracked from a third party: advancing a pin is a
local decision, and there is no external maintainer to wait on for the patch
itself. What the fork still waits on is the *upstream* fix that would let the pin
be dropped entirely, recorded below.
Each entry records what the fork changes and what has to be true before it can go
back to the published package, so a future upgrade does not have to rediscover it.
Re-verify a fork by diffing its pub cache checkout against the published version
of the same number:
```bash
diff -ru ~/.pub-cache/hosted/pub.dev/<name>-<version> ~/.pub-cache/git/<name>-<sha>
```
The `window_manager` fork (`chen08209/window_manager`, tag `v0.5.1-flclash.3`)
was replaced by the in-repo `plugins/window` package; the behaviors it carried
(work-area maximize with a hidden title bar on Windows, geometry restore on
re-map on Linux, corner preference, terminate and activate events) live there
now, see the window plugin section of `architecture.md`.
`launch_at_startup` — `chen08209/launch_at_startup`, version 0.5.1.
- Migrates `win32_registry` from `^2.0.0` to `^3.0.3`, which is a breaking rename
across the whole Windows implementation (`Registry.openPath` → `CURRENT_USER.open`,
`createValue` → `setValue`, `getStringValue` → `getString`).
- This one is not optional while it lasts: FlClash depends on `win32_registry: ^3.0.3`
directly, and upstream's `^2.0.0` constraint cannot co-resolve with it.
- Drop the fork when upstream publishes a release that accepts `win32_registry` 3.x.
The `yaml_writer` fork (`chen08209/yaml_writer`) was replaced by
`lib/common/yaml_writer.dart`. Its key quoting let keys such as `!a`, `'a` or
`0x1F` reach the Core's yaml.v3 as a parse error or a non-string; a merge key
`<<` stays plain on purpose, because `package:yaml` keeps it as an ordinary key
and the Core applies the merge.
The `code_forge` fork (`chen08209/code_forge`) is frozen: its Dart code now lives
in the in-repo `plugins/code_forge` package and its Rust code in the `editor/`
module of `plugins/rust_api`, both trimmed to what FlClash uses.
`plugins/code_forge/README.md` records what was kept and what was removed;
upstream's MIT notice stays in `plugins/code_forge/LICENSE`.
## Dependency Ceilings
`flutter pub outdated` lists only upstream-held entries, so treat them as
resolved-until-the-ceiling-moves rather than as debt:
- `intl` is intentionally unbounded (`any`) and `material_color_utilities` is
resolved by the Flutter SDK; neither is a bound this repository sets.
The `riverpod` set, `build_runner`, `drift_dev` and `intl_utils` resolve to their
latest releases. Two are held back:
- `freezed` 4.0.2 needs `analyzer` 14, while `intl_utils` 2.8.16 still requires
`analyzer` ^13. It moves once `intl_utils` accepts analyzer 14.
- `test` 1.32.0 needs `test_api` 0.7.14, and `flutter_test` on Flutter 3.47 pins
0.7.12. It moves with the SDK.
When an upgrade fails version solving, record the blocker here with
what has to change upstream before the pin can move, so the next
`flutter pub outdated` run does not rediscover it.
`_initDynamicColor` in `lib/bootstrap.dart` reads the `dynamic_color` method
channel directly and decodes the palette list into a `TonalPalette`, instead of
going through `DynamicColorPlugin.getCorePalette()` and its deprecated
`CorePalette` return type. Everything downstream sees `DynamicColorSeeds`, so a
non-deprecated plugin accessor changes only that one function.
## Build Dependencies
Linux:
```bash
sudo apt-get install libayatana-appindicator3-dev
```
Windows:
- GCC and Inno Setup.
macOS:
```bash
npm install -g appdmg
```
+663
View File
@@ -0,0 +1,663 @@
# Rules
These are repository coding and testing conventions. Codex command permission rules belong in `.codex/rules/*.rules`; see `.agents/agent-config.md` before adding those.
## Dart and Flutter Style
The lint set lives in `lint_options.yaml` at the repo root. The root `analysis_options.yaml` and every local plugin under
`plugins/*` include it, so application and plugin code are held to the same rules. Add or change a rule there, not in an
individual `analysis_options.yaml`; those files carry only their own `analyzer.exclude` entries.
`lint_options.yaml` enforces these non-default rules:
- `prefer_single_quotes: true`: always use single quotes.
- `require_trailing_commas: true`: use trailing commas in multi-line argument lists.
- `sort_child_properties_last: true`: `child:` must be the last named parameter.
- `avoid_print: true`: do not use `print()` calls.
- `prefer_const_constructors: true` and `prefer_const_declarations: true`.
- `prefer_final_locals: true` and `prefer_final_in_for_each: true`.
- `always_declare_return_types: true`.
- `only_throw_errors: true`: throw an `Exception` or `Error`, never a bare `String`.
Failures whose whole content is a message meant for the user throw
`MessageException` from `lib/common/exception.dart`. Its `toString()` is the bare
message, which is what `globalState.safeRun` surfaces in the dialog, so the
user-facing text is unchanged from the older `throw someMessage` idiom while the
throw stays catchable as an `Exception` and carries a stack trace. Assert on it
with `isA<MessageException>().having((e) => e.message, 'message', ...)`, not on a
raw string.
### Corner Radius
Rounded corners are superellipses (continuous corners) everywhere, never circular arcs; when a component offers
both, pick the superellipse. `test/lint/superellipse_corners_test.dart` fails on the circular APIs below in `lib/`. Use
the superellipse API at each layer:
- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`.
- Clips: `ClipRSuperellipse` instead of `ClipRRect`.
- Container decorations: `ShapeDecoration(shape: RoundedSuperellipseBorder(...))` instead of
`BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with
`clipBehavior` still clips to the shape path.
- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`.
- Ink: `InkWell.customBorder` with an `AppShape` instead of `InkWell.borderRadius`.
Passing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only
carries the corner magnitude; the rendered geometry stays a superellipse.
Pills are no exception. `StadiumBorder` joins its round caps to the straight sides with a curvature jump, while
`AppShape.full`, a superellipse at full radius, blends them, and on a square it renders the exact circle, so it serves
circles too. Material 3 defaults every button, icon button, segmented button, and search bar to `StadiumBorder`;
`ThemeData.withAppShapes` replaces that for the button themes, and a local `FilledButtonTheme` or `IconButtonTheme`
replaces the app's, so merge the ambient `XButtonTheme.of(context).style` into it, as `CommonMinFilledButtonTheme`
does. `CircleBorder` and `BoxShape.circle` are fine for something that is always square.
APIs that accept only a `Radius`, such as `ScrollbarThemeData.radius`, keep circular corners; supply the superellipse
with an enclosing clip or shape where it shows. Framework widgets that paint their own circular corners with no shape or
decoration to override, such as the `Switch` track, are left as Material draws them. Tooltips take theirs from
`withAppShapes`, which sets `tooltipTheme.decoration`.
### Typography
Text sizes follow Apple's iOS Dynamic Type scale at its default size, on every platform. `AppTextStyle` in
`lib/ui/typography.dart` holds its eleven styles, `largeTitle` to `caption2`, with Apple's leading and weights, plus two
steps the scale lacks: `display` for a dashboard figure and `pageTitle` for an app bar. Take one with
`AppTextStyle.body.of(context)`, pass `emphasized: true` for the scale's emphasized weight (bold for the titles,
semibold for the rest), and change color or family with `copyWith`; a weight is either regular or emphasized. Text
sized to the shape it marks, such as a letter scaled to its icon, comes from `AppTextStyle.custom`. The dashboard's
roles (`DashboardText` in `lib/views/dashboard/style.dart`) are built on the same types. `ThemeData.withAppTypography`
maps Material's `TextTheme` slots onto the scale so Material components follow it; app code never reads those slots.
App bar titles sit at the leading edge in `pageTitle` on a root page and on any desktop page, at the leading edge in
`headline` on a pushed page in the mobile view, and in the bar's centre in `headline` on a bottom sheet. A title widget,
such as the editor's name field, stays at the leading edge. Past a back button a title keeps the bar's action inset
clear of the button on either side.
`test/lint/typography_test.dart` fails on `textTheme` anywhere in `lib/` but `typography.dart`, and on `fontSize:` or
`fontWeight:` outside `lib/ui/`, except in the code editor, whose text follows the editor font-size setting.
### Text Field Suggestions
On Android, Flutter maps `enableSuggestions: false` to the visible-password input type, not only to "no suggestions"
(`TextInputPlugin.inputTypeFromTextInputType` in the engine). Input methods then treat the field as a password: no
clipboard panel, no Chinese layouts, and on some systems a secure keyboard. With the app's clipboard permission denied,
that leaves no way to paste a link at all. Only the secret fields, `SecretTextFormField` and `FormRowSecretTextField`,
ask for a password keyboard; a link, key, rule or regex field that should not be corrected sets `autocorrect: false`
alone. `test/lint/text_field_suggestions_test.dart` fails on `enableSuggestions: false` or
`TextInputType.visiblePassword` anywhere else in `lib/`.
### Length Units
Flutter lengths are logical pixels, the unit Android calls dp. Comments, test names, docs and reports give them in dp,
as in `48 dp`, never px: a reader takes px for physical pixels and misjudges the size by the device pixel ratio. Write
"physical pixel" in full where the code really works in them, as the hairline snapping in
`lib/ui/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in
`tool/` and CSS under `site/` keep px. `test/lint/length_unit_test.dart` fails on a number followed by px in `lib/`
and `test/`.
CI gates formatting: `dart format --output=none --set-exit-if-changed lib test
tool plugins setup.dart` runs before `flutter analyze`.
These directories are excluded from analysis:
- `build/**`
- `lib/l10n/intl/**`
- `lib/**/generated/**`
- `plugins/**`
- the platform runner directories: `android/`, `ios/`, `web/`, `windows/`, `macos/`, `linux/`
## Comments
Comments are part of the code and some of them are load-bearing. What this repository limits is the useless and the
frequent. Every comment that restates the code devalues the ones carrying real information, until readers skim past all
of them — a file with three comments that matter is more readable than one with thirty.
The two halves are enforced differently, because only one of them can be counted. Whether a comment is useless is a
judgment, and it lives in the rules below and in review. Whether comments are too frequent is arithmetic, and
`comment-density` measures it.
### What a Comment Must Earn
- Write one when it carries something the code cannot: a non-obvious constraint, an upstream or platform behavior being
worked around, a reason the next reader would otherwise get wrong, a coupling to a value defined somewhere else.
- Never restate what the code already says. If the comment and the line below it convey the same thing, the comment is
noise.
- Never narrate. Not what this diff changed, not what the code used to be, not the order you did things in. Version
history belongs in git and change rationale belongs in the commit message.
- Never annotate line by line or statement by statement. A block that seems to need a comment per step needs better
names or a smaller decomposition instead — reach for structure before prose.
- Prefer encoding the intent in structure and naming. A named mixin, type, or method that makes the invariant hard to
break beats a paragraph asking the next reader not to break it.
- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file
that contains them. This does not need approval.
- These are not comments and are exempt from every rule here, including the density count: analyzer and linter
directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,
code-generation markers, and comments inside vendored upstream code such as `lib/ui/open_container.dart`.
### Density
`tool/hooks/comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring
diffs under 20 added lines so small edits are never caught. The number is calibrated on this repository's own history:
healthy changes sit at or under 3.6%, while the core fix that prompted the gate ran 22.4%.
It is a ceiling on frequency, not a target to fill. Do not read `core/`'s inherited mihomo density as a quota either —
it is forked upstream code, not a house style.
Three gates run the same script, and they do not have equal force. The `PostToolUse` hooks in `.claude/settings.json`
and `.codex/config.toml` run after the tool and hand offending lines back as feedback; neither can undo the completed
write. The `comment-density` pre-commit hook fails the commit, and it is the only hard gate that covers every tool.
Behavior is pinned by `tool/hooks/comment_density_test.sh`, which CI runs through `tool/hooks/test.sh`.
When a change genuinely warrants more, raise the ceiling for that run rather than working around it:
`COMMENT_DENSITY_MAX=20 git commit`, or `SKIP=comment-density git commit` to step past it entirely.
### Where Knowledge Belongs
A comment is one of three destinations, and often the weakest. Pick by where the constraint would be violated, not by
how important it feels.
- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior
it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.
- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They
are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do
that.
- **A fact that is true only at one call site, and is not visible from that call site, is the one thing a comment does
better than a test or a document.** Its value is being in the reader's line of sight at the moment of the edit.
`lib/common/constant.dart` carries one: the delay-test concurrency cap is bound to `delayTestConcurrency` in
`core/delay.go`, and whoever changes that number must see the constraint on the same screen. This is the case a
comment is for; the bar is that no test and no `.agents/` entry could hold the fact instead.
Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;
leaving a repo-wide policy as a comment reaches only the reader of that one file.
## Core API Safety
- `core/Clash.Meta` is a fork of mihomo, and changes to it are budgeted for features, not repairs. Fixing a bug there is
low priority even when the bug is real and the fix is small: every patch is one more thing to carry across an upstream
rebase. Solve it on the FlClash side of the boundary and note the mihomo behaviour you are working around. Reach into
the submodule only for a feature that has nowhere else to live, or when the problem is one the FlClash patches
themselves introduced — and say which of the two it is in the commit message.
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
a scope-specific cleanup API instead.
- The desktop IPC socket in `plugins/rust_api` admits a Unix peer only when its effective uid is the app's own or root:
the socket lives in `/tmp`, and the Core connects as root whenever it runs setuid for TUN or under the Linux Helper.
A launch mode that runs the Core as any other user has to widen `admit` in `ipc/access.rs`, and Windows
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, the
gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches. A response whose
`file` is set was spilled to disk; a transport resolves it with `resolveSpilledResponse` before reading `result`.
- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,
route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest
entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push
out the request events the connection list is built from; state uses `enqueueState`, which never evicts, because a dropped `geoUpdate{updating:false}` leaves `isUpdatingProvider` stuck at true in the UI until
`UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full
queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports,
and reporting it from the message layer feeds the same batcher.
- Every method of the callback interfaces in `core/mobile` (`TunHandler`, `ResultHandler`) returns an `error`. gobind
clears a Java exception and hands it to Go only for a method that declares one; otherwise the exception stays pending
and the next JNI call on that thread aborts the process, so a throw in `protect`/`resolveUid`/`resolvePackage`/
`onResult` would crash unrelated code. `core/mobile` turns the error into the answer the system gives when it will not
help: `false`, `-1`, or an empty package name.
- `TunHandler`'s hooks in `core/lib.go` check `callbacks` under `th.mu` before every call. `clear` drops the callbacks
while connections may still be resolving, and a call through the nil interface panics on a mihomo goroutine, outside
every recovery.
- The Android bridge resolves an owner in two steps — `ResolveUid` then `ResolvePackage` — because mihomo fills
`metadata.Uid` from a procfs lookup that Android Q closed off. Collapsing them back into one call that returns only a
package name is what left every connection reporting uid 0, so `UID` rules matched nothing.
- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to
the log subscriber, batched, and handed back to `send`, so a send failure reported that way feeds itself; use
`logDeliveryError`, which writes to stderr and latches until a frame gets through or the next connection is installed.
A write that fails without putting a byte on the wire — host backpressure hitting `ipcWriteTimeout`, or a payload above
`maxIPCFrameSize` — drops that one frame and keeps the connection: the stream is still framed correctly, and tearing it
down here ends the read loop, and with it the Core process. A half-written frame whose write merely timed out is
resumed for as long as the stall lasts: Windows Modern Standby suspends the app while the Helper's Core keeps running,
so the host can stop draining for hours and still come back, and go-winio reports the expiry as its own `ErrTimeout`
rather than `os.ErrDeadlineExceeded`, which is why `send` checks `Timeout()`. The wait has no cap on purpose, and
`send` holds `writeMu` throughout, so every other frame — method responses and the single batcher goroutine behind
the event queues — waits behind the stalled one. Memory is bounded by the queues; what gives is delivery: the state
queue fills and `enqueueState` starts dropping, which is the case the `UpdatingAction` sweep above exists for. Nothing
on the Dart side restarts the Core over a stall: `CoreRpcClient` times each pending request out on its own and hands
the caller `null` (a `no_response` exception for message methods), and the sweep clears core-scope updating state
minutes later. Only a half-written frame that fails outright desynchronizes the stream, and that is the one case
`send` closes on.
- Core method handlers are plain functions that return their answer, registered in the `methodHandlers` table in
`core/method.go` through the adapter matching their shape. `dispatchMethodCall` already runs every call on its own
goroutine and `handleMethodCall` turns a panic into an `internal_error` answer, so a handler blocks for as long as its
work takes and never spawns a goroutine just to answer. Work that outlives the answer goes through `safeGo`, which
recovers; a bare `go` puts a panic outside every recovery and kills the process, which on Android is the whole
application. The exported Android entry points in `core/lib.go` do not reach `handleMethodCall`, so each one carries
its own recovery.
- A handler fails by returning an `error`: a `*MethodError` travels as it is and anything else under `core_error`. The
exception is `withMessage`, for the methods whose result the host reads as a message that is empty on success
(`_invokeMessage` in `lib/core/interface.dart`): there a plain error becomes that message and only a `*MethodError`
fails the call. Moving a method between the two changes what Dart receives.
- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in
`core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the
socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func
value. Stopping the TUN swaps `activeTunHandler` instead.
- `tunnel.AllProxies()` returns a shared, cached map — never modify it. The cache is invalidated by
`invalidateAllProxies` on `tunnel.UpdateProxies` and validated against each provider's `Version()`, so a rebuild
costs one read per provider rather than one per proxy. Anything else added to `tunnel/patch.go` that derives from the
proxy set needs both signals: the external controller can reload the config through `hub/route/configs.go` without
going through FlClash's `applyConfig`, so a hook on the FlClash side alone would miss a profile switch.
- Core state that mirrors mihomo state goes stale at the next `applyConfig`, which replaces every proxy, provider and
rule. Read the tunnel instead of caching a snapshot of it: `lookupExternalProvider` kept one that was rebuilt only
when the host asked for the provider list, and the host asks after a successful setup and not after a failed one, so
an update ran against a provider the tunnel no longer held — downloading, writing to disk and reporting success
against nothing.
- Selection writes take `selectMu`, not `configMu`. mihomo's `Selector.Set` has no lock of its own, so the writes need
mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is
what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under
`configMu`, fixing the order as `configMu` → `selectMu`.
- Queueing for the delay-test semaphore and probing the node each get the full timeout in `handleTestDelay`. With one
shared deadline, a node that waited behind a saturated semaphore reached `URLTest` with almost nothing left, so a bulk
test of a large subscription reported whatever sat at the back of the queue as unreachable.
- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy:
`handleTestDelay` returns within its two timeouts on every path. `asyncTestDelay` therefore returns null instead of a
`-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a
timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's.
- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0
used to mean "testing", which let a result and the state of a test overwrite each other and left cards spinning
forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a
reply arriving; core status leaving `connected` cancels every run in flight.
- Some constants in `lib/common/constant.dart` mirror the Core and move with it. `delayTimedOutValue` is the `-1` that
`core/delay.go` reports for a delay test that ran out of time; any other negative value failed outright.
`downloadTimeoutDuration` matches mihomo's `resource.DefaultHttpTimeout`, which bounds the Core's provider downloads
end to end. `shareLinkSchemes` are the schemes mihomo's `ConvertsV2Ray` reads, less http(s), which is a subscription.
- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background
scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the
batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData`
claims per resource and `updater.GeoUpdateHook` releases.
- A failed `applyConfig` rolls the tunnel back to the default config, and that rollback is the whole recovery:
`handleSetupConfig` returns the error and stops there. Do not add a teardown on top of it — stopping the listeners
takes the app offline over a profile the user can still switch away from, and the error already reaches the host,
which is what surfaces the failure (`MessageException` on the Flutter side, the config-error toast on Android). Keep
an empty `config.yaml` out of the failure path — it is how the app says "no profile selected", and `loadConfig`
resolves it to the defaults.
- Package `init` in the Android library runs while the `.so` is being loaded, so a panic there takes the application
down before it can report anything. `platform/limit.go` arms an fd-pressure probe and degrades to never blocking when
it cannot; keep that shape for anything else `init` sets up that correctness does not depend on.
- No file in `core/` uses cgo: the only C in the Android library is the glue gobind generates during `gomobile bind`.
Android-only files take `//go:build android` and desktop-only ones `!android`, so `GOOS=android CGO_ENABLED=0 go vet`
checks the Android side without an NDK. Keep it that way; cgo in `core/` puts the NDK back in front of that vet and of
`go build ./...` on every developer machine.
## Lifecycle Rules
- Crash recovery is owned by `BootGuard` (`lib/common/boot_guard.dart`), and the signal it acts on is the persisted
`BootRecord`, never a crash reporter. The record is stamped `starting` before `startCore` and `running` once
`_initApp` finishes, so only a launch that died before reaching `running` is a failed launch — a crash after hours of
runtime, or a process the system reclaimed in the background, is not one. `ApplicationExitInfo` can only veto a
failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through
`handledExitAt` so the same exit cannot be counted twice.
- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and
`markClosed` return without touching preferences. The desktop has no `ApplicationExitInfo` equivalent to attribute an
exit, so a bare sentinel there would read a window closed on the disclaimer dialog, or a session ended by shutdown,
as a failed launch. Keep the platform check inside the guard; callers in `bootstrap.dart` and `SystemAction` stay
unconditional.
- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from
`crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config.
- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets,
managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`.
- `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must
stay centralized in `SystemAction`/`SystemExitCoordinator`.
- Android start/stop MethodChannel calls are optimistic UI commands. Keep latest-wins arbitration in native
`ServiceState`; do not add a Flutter completion callback that creates a second lifecycle owner. What flows back is
the revision-stamped `stopped` report, which Flutter mirrors with `setRunning(false)` and never answers with a
decision of its own.
- Android `RunState` is derived in `ServiceStateMachine.publish`; do not assign it from a transition step, and do not
add a second "is it running" field beside `ServiceController.runtime`. Every divergence between the tile and the
service so far came from a copy that one path forgot to update.
- Android service callbacks are not automatically user intent. Route explicit Quick Settings, Always-on VPN, and revoke
actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.
- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the
broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.
- Anything that probes through the outbound route is a `RoutedProbe` judged against `routeTrackerProvider` (Route
Consistency in `.agents/architecture.md`); the Core stamps every answer and publishes its picks, so do not add a
change signal, a trigger counter, or a `coreStatusProvider` watch of your own.
- Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or
overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately.
- Safe mode (`SAFE_MODE` define, see the safe mode section of `.agents/architecture.md`) is consulted by each host
integration at its owner, never by rewriting the user's settings. `safeModeBuild` is for handles created before the
container exists; everything else reads `safeModeProvider` so a test can override it.
- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to "`show` was never called".
`AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins.
Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)`
means `WM_DESTROY` never runs.
- The `tray` plugin owns call ordering, idempotency, serialization, and unchanged-payload suppression. Application code
declares desired state through one `Tray.show(TraySpec)` call and must not add platform branches to work around
ordering. Platform branches in `lib/common/tray.dart` are only for deliberate product differences (macOS speed title
and group submenus); query `Tray.instance.capabilities` for ability differences.
- Every native `show` returns whether the tray accepted the payload, and reports `false` instead of showing a broken
icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather
than suppressed until restart. Windows accepts a payload the shell refused, as at sign-in, and retries the icon
natively until it lands, so `Tray` still treats it as visible and `hide` reaches native to delete an add that may
have landed. Any test that mocks the `tray` channel must return `true` from `show`.
- Reading the Wi-Fi SSID is opt-in work, not ambient state. `ConnectivityManager` reads it only while `excludeSSIDs` is
non-empty, because that list is its only consumer through `suspendProvider`, and the read costs a blocking platform
call plus a location permission on Android and macOS. A second consumer must widen that gate, not drop it.
- On Android the `wifi_ssid` permission is only `granted` once `ACCESS_BACKGROUND_LOCATION` is held too (Q+). Fine
location alone reads the SSID in the foreground but returns `UNKNOWN_SSID` once the app is backgrounded, which is
where on-demand spends most of its time. `getSsid` itself only needs fine location, so the split stays inside the
plugin.
- No `wifi_ssid` implementation may answer `getSsid` on the platform thread. Android resolves through a network callback
with a timeout, Linux through `g_task_run_in_thread`, and macOS through `ssidQueue` after checking the location
authorization, because CoreWLAN reaches `wifid` over XPC and a wedged daemon would freeze the window. Windows is the
outstanding exception: `WlanQueryInterface` still runs inline, since replying off-thread there needs a window-message
hop that the plugin API does not provide.
- The delayed DNS re-check `NetworkObserveModule.onLosing` posts is deliberately left un-deduplicated. The runnable
re-reads `networkInfos` and does nothing when `onLost` already dropped the network, `updateDns` calls into the Core
only when the resolved list or the preferred network changed, and `stop()` clears the handler queue, so a network that
reports `onLosing` repeatedly costs two comparisons per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds
state that has to stay in sync with the map.
## UI Isolate Work
Everything the UI isolate runs synchronously shares a frame with build, layout and paint: 16 ms at 60 Hz, 8 ms at
120 Hz. Computation may take as long as it needs anywhere else, so this rule is about where work runs, not how much of
it there is.
- Work that grows with the user's data (profiles, rules, proxies, groups, dialers, icons, connections) and can take
more than about 8 ms on the largest data the app expects runs in an isolate, through `compute` or `Isolate.run`.
Thousands of rules or proxies and hundreds of groups are in that range. `makeRealProfileTask` in
`lib/common/task.dart` and the backup tasks in `lib/common/backup.dart` are the existing shape.
- Make the algorithm linear or cached first. Work superlinear in that data, such as a walk of the whole graph per
item, a scan of every group per group, or a list copy per inserted element, is a defect on any isolate; moving it
only hides the cost until the data outgrows the move.
- Sending data to an isolate copies it on the sending isolate, so hand the task the smallest input it reads. A value
the UI reads synchronously, such as a sync provider watched in `build`, becomes an async provider with a loading
state rather than a blocking call.
- drift runs SQL on its own isolate but maps rows into models on the caller's, so a watched query over a large table
costs the UI isolate on every emission, and any write to that table makes it emit again.
- Bounded work stays where it is: a provider recompute that reuses a cache, a single-item update, a search filter over
a list sized for scrolling.
- Decide on measurements taken at the expected maximum. A `flutter test` probe that counts provider recomputes, times
the provider body and hooks `debugOnRebuildDirtyWidget` shows what reruns and how it scales; its JIT timings are not
device numbers, so a case near the line is settled with a profile build. No gate checks this rule.
## Testing Rules
The `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage
collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper,
plus a `GOOS=android` vet that covers the `android` files the first two exclude; verify cross-language
protocol behavior through shared Dart contract tests under `test/core/` and native platform build checks.
A Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` —
leaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever
connection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with
`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test
connection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches
it in an unrelated test.
Register fallback values for freezed params used with `any()` matchers.
`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a
group's floor when new tests lift it; do not lower one to make a run pass.
Every measured group needs a floor. A group the report measures but `_groupFloors` does not declare fails the run, so
adding a top-level directory under `lib/` means adding its floor in the same change. Set a new floor at or just below
the coverage the directory actually has; the point is to stop a slide, not to backfill tests before the directory can
land.
Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and
touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read
resolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim
the singleton calls `CoreController.resetInstance()` in `tearDown`. It also only reaches the global `coreController`
when nothing has read it yet: that is a top-level `final`, bound to whichever instance existed at the first read, which
in a shared isolate is another file's.
### Shared Test Isolate
`tool/test.dart` imports many test files into one entrypoint, so process-wide state outlives the file that set it, and
every file's `main()` runs before any test of the entrypoint does. Keep `main()` to declarations and
`TestWidgetsFlutterBinding.ensureInitialized()`: a mock installed there is replaced by the next file's before a test
sees it. Set up what the tests need in `setUp` or `setUpAll` instead, from the helpers that rebind the singletons:
- `appPath` resolves its directories once per process. Point them at a test's directory with `await useAppPaths(dir)`
from `test/helpers/app_paths.dart`, not with a `PathProviderPlatform` fake, which only works while nothing has asked
`appPath` yet.
- `preferences` opens its store once per process. Use `await mockPreferences(values)` from
`test/helpers/preferences.dart` rather than `SharedPreferences.setMockInitialValues` alone, which leaves `preferences`
on the store it already opened.
Both cache the resolved value, not the future that produced it. A future completed in one test's fake-async zone runs
its listeners in that zone, so a later test that awaits it never resumes. Await the helpers outside `testWidgets`
bodies for the same reason.
A test that only passes in a fresh isolate is leaning on a global it did not set up: `database_test.dart` once caught
its write failures only because `currentAppLocalizations` threw before anything had loaded them. The script reruns such
a file alone so it cannot fail the run, and lists it; fix it where it reads the global.
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
path. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`.
Three globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it:
`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every
`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry
key), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early
under `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test
at all; `test/common/launch_test.dart` pins the early return instead.
`pumpAndSettle` never returns on a page holding `EditorView` (what `EditorPage` embeds): the code editor blinks its
caret forever, so frames keep being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in
`common/task.dart` hand work to a real isolate through `compute`, which only runs outside the fake-async zone, so a test
awaiting one needs `tester.runAsync(...)` between the pumps.
The `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it
replaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the
setter is not, so closing there would either be unawaited or force the seam to become async for no gain. A test that
does open a file-backed database owns closing it.
`system.isAndroid` / `isMacOS` / `isWindows` / `isLinux` read `dart:io` `Platform` and cannot be overridden, unlike
`debugDefaultTargetPlatformOverride`. A branch behind one of them is only ever exercised on a host that matches it, so CI
(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior,
and leave headroom under a group floor that covers such a branch.
A platform decision that drives layout takes `isDesktop`/`isMacOS` as parameters and reads `system` only at the call site,
so every platform's outcome is reachable from one host. `getWindowHeaderHeight` and `showsWindowHeader` in
`lib/common/layout.dart` own the window header rule for both `WindowHeaderContainer` and `overlayTopOffset` — they must
agree, or the content is offset by a header that is not there. `WindowHeaderBar` takes its height and slots as arguments,
which is what lets `test/manager/window_header_test.dart` measure the Windows caption bar on a macOS host.
A `Stack` under loose constraints sizes to its non-positioned children, and falls back to `constraints.biggest` only when
it has none — so a bar that fills the window must keep every slot positioned. One non-positioned child is enough to
collapse `WindowHeaderBar` to that child's width: the macOS title did exactly that, leaving the app name over the traffic
lights and the raw window painted black beside it, while Windows, whose slots are all positioned, stayed correct.
`WindowHeaderLayout` positions the header across the top for the same reason, and the macOS group asserts the bar's
width, not just its height — a height-only assertion cannot see this.
The same seam carries the two other places a platform decision changed what was built: `AppTray` holds `isMacOS`/
`isWindows` as state — `AppTray()` fills them from `system`, `AppTray.forPlatform` is the test seam — and `OnDemandView`
takes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because
`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS
shape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every
non-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window` channel mock that
answers `isMaximized`/`isAlwaysOnTop` with a bool.
Auto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,
`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching
rebuilds the provider from its override and silently discards whatever the code under test wrote. Add
`container.listen(theProvider, (_, _) {})` in the harness, as `test/providers/action_test.dart` does for `scriptsProvider`.
A field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`,
`PageController`, `AnimationController` or `StreamController` must be released in the same file.
`test/lint/disposable_field_test.dart` enforces this by scanning `lib/`, because no lint covers it: `close_sinks` only sees
sinks, and nothing in the standard set tracks `ChangeNotifier` disposal. A field that genuinely outlives its owner goes in
that test's `_allowed` set with the reason, not left bare. Controllers received as widget parameters belong to the caller
and are out of scope.
An `IconButton` whose icon is an icon needs a `tooltip`. It is the button's only accessible name — without it TalkBack and
VoiceOver announce nothing and the desktop build shows no hover hint. `test/lint/icon_button_tooltip_test.dart` enforces
it and skips exactly two shapes: an `icon:` holding a `Text`, which is already a visible label, and
`views/dashboard/widgets/core_status_button.dart`, which takes its label from an enclosing `Tooltip` (a second test fails
if that wrapper disappears). Reuse an existing string before adding one; a label that depends on state goes on the button
inside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden
behind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern.
A tooltip needs an `Overlay` ancestor at build time, not at hover time: `RawTooltip` builds an `OverlayPortal`, so a
button whose `tooltip` has nowhere to go throws "No Overlay widget found" and takes its whole subtree down with it.
Everything `buildManagerStack` wraps around `MaterialApp.builder`'s child sits *above* the app Navigator and therefore
above the only Overlay in the tree. A manager that renders a tooltip — `WindowHeaderLayout` and its caption buttons are
the case that broke Windows while macOS, which renders a bare title there, stayed clean — hosts its own with
`Overlay.wrap`, spanning the window so the tooltip is not clipped to the widget that owns it. A test only reproduces this
by mirroring that topology: build the widget from `MaterialApp.builder`, never from `home:`.
A public top-level declaration that nothing outside its own file references is dead, and no lint catches it:
`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every
"unused import" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the
`final appPath = AppPath()` singletons next to them — appear nowhere else, counting generated code as a consumer (a
riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or
typedefs are skipped: those are reached through the types they attach to, never by name.
`CommonScaffold` adds the height of its docked search to `BottomInsetScope` around its body, so a page that reads
`BottomInsetScope.of(context)` with the context it builds the scaffold from gets the inset without it, and the end of
its content stays under the search field. Read it below the scaffold,
inside `body: Builder(builder: (context) => ...)`. `test/lint/scaffold_bottom_inset_test.dart` enforces this by
scanning `lib/`.
Every reorderable list builds its drag proxy with `commonProxyDecorator`, which tells `DecorationListItem` it is lifted:
the row drops its separator and rounds from its corners in the run to a card of its own as it lifts, and back as it
drops. A list without it lifts a middle row with square edges. `test/lint/reorder_proxy_decorator_test.dart` enforces
this by scanning `lib/`.
A `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then
immediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown
throws "`…State.dispose failed to call super.dispose.`" in debug and profile builds. Declare the override as `void
dispose()` and hand async teardown to `unawaited(...)`; `Future<void> dispose() async` compiles and is the shape that
invites the bug.
Use `ProviderContainer` directly for simple Riverpod provider tests, released with `addTearDown(container.dispose)`.
Assert against the provider's generated public API, not its implementation, re-read source defaults before asserting
them, and wait on provider futures or state changes rather than fixed sleeps. The generated Riverpod `update()` method
takes a callback:
```dart
notifier.update((state) => newValue);
```
When testing freezed models with nested objects, always round-trip through `jsonEncode` and `jsonDecode`. Direct `fromJson(toJson())` fails for nested freezed types because `toJson()` stores child objects directly instead of maps.
For async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success,
failure, disposal, and any timer boundary that changes visible state.
## Running the App Locally
- An agent starts FlClash on the host only as a safe mode build (`--dart-define=SAFE_MODE=true`, see
`.agents/commands.md`). A normal build takes over the host network, so the user runs it, never the agent.
- Never end a FlClash process the agent did not start, and never kill by name (`pkill`, `killall`, a pattern on the
executable path): the user's own instance matches too. Record the pid of every process you launch, check with
`ps -o pid,command -p <pid>` that its executable is your own build output, and end only that pid.
- Only a macOS or Android debug build has its own data directory; every other build shares the installed app's data
directory and single-instance lock, so it cannot start while the user's instance runs. `.agents/commands.md` lists
which builds are kept apart.
## Commit Messages
Subjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs
`tool/hooks/commit_msg.sh`:
```text
<type>[(scope)][!]: <description>
```
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.
- Scope is optional and lower case; use a comma to list several, as in `fix(core,android)`.
- `!` before the colon marks a breaking change.
- Descriptions start in lower case, omit the trailing period, and keep the whole subject within 100 characters.
Identifiers and acronyms keep their own casing, as in `fix(ui): AppBar text is truncated`.
- `Merge`/`Revert` subjects and `fixup!`/`squash!` commits are exempt.
- No `Co-authored-by` trailer crediting a coding agent, whatever that tool's own convention says. The history
records who owns the change, not which tool typed it; human co-authors are still fine. The hook rejects the
known agent identities.
Write what the change does, not that something changed: `perf(views): stop redoing per-frame work in build`, not
`Optimize more details`.
Install the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`.
### Fixup Commits
A follow-up to a change that has not shipped in a stable release goes in as `git commit --fixup=<target>`, aimed at the
commit that introduced what it amends.
- Only stable tags (`vX.Y.Z`) close a commit. A target reached only by pre-release tags (`vX.Y.Z-pre.N`) is still open
for fixups, so do not steer away from it or flag it.
- A commit a stable tag contains takes no fixup; land the follow-up as a regular commit. Check with
`git tag --contains <target> | grep -v -- '-pre'`, which must print nothing.
- Asking for a fixup means creating the `fixup!` commit only. Do not run `git rebase --autosquash` or otherwise fold it
into its target unless the user asks for the squash.
### Changelog Trailers
`tool/changelog.dart` builds the user facing changelog from the commit history, so the trailers below are the copy that
ships to users. The subject stays the developer facing summary and is only the fallback.
```text
feat(profiles): support per-profile override script
Changelog: Per-profile override scripts
```
- `Changelog:` is the English entry. `Changelog: skip` drops the commit from the changelog entirely.
- The changelog is English only. A `Changelog-<locale>:` or `Breaking-<locale>:` trailer fails the hook, because
translated release copy does not belong in the commit history. Translate after the fact if ever needed, not in the
commit message.
- `Changelog-Type:` moves an entry into another group, for example to promote a `refactor` that users will notice. Valid
values are `breaking`, `feat`, `fix`, `perf`, `revert`.
- `BREAKING CHANGE:` is required whenever the subject carries `!`, and its text becomes the breaking entry. A `!` commit
therefore needs two lines of copy: the footer for the breaking entry and `Changelog:` for the normal one.
`feat`, `fix`, `perf`, `revert` and breaking commits are collected by default; every other type is dropped unless it
carries a `Changelog:` trailer. Commits missing a trailer reuse their subject; for `feat`, `fix` and `perf` the hook
says so without blocking.
## Migrations
An upgrade starts from the last release, not from the previous development build. Before writing a legacy reader, a
data move, or a test for one, check that the state it reads shipped: `git tag --contains <commit>` on the commit that
introduced it, or `git show <tag>:<path>` for the file as the release had it. A state that only ever existed between
two releases gets no migration; a development install holding it may lose that setting or fall back to its default.
A release that resets or rewrites settings, instead of reading their old shape, adds a step to `_upgrades` in
`lib/common/migration.dart` under the next `upgradeVersion`. A step takes and returns the `Config`, runs once on an
install that already had settings, and never on a new one. Leave `currentVersion` for changes to the stored format:
backups carry it, and an older app refuses a backup stamped newer than its own, so bumping it for a reset would strand
backups between devices on different releases.
The database takes one migration step per release. The newest step in `lib/database/database.dart` has not shipped
while `schemaVersion` at the last release tag is below its number; a schema change then goes into that step, raising
`schemaVersion` and the step's condition together so a development install runs the step again, instead of adding a
step of its own. Every operation in it checks the shape it finds, a missing table or column or a column under its old
name, so it can run again on a database that went through part of it. Its tests start from the released shape.
## Generated Code
Do not manually edit generated files under:
- `lib/l10n/l10n.dart`
- `lib/models/generated/`
- `lib/providers/generated/`
- `lib/database/generated/`
- `lib/ui/generated/`
- `lib/l10n/intl/`
After schema, model, or provider changes, run build generation and include focused tests when behavior changes.
`lib/l10n/l10n.dart` is the one file that still imports `package:flutter/material.dart`.
`intl_utils` hardcodes that import in its own template, so regenerating rewrites it and
there is nothing to fix here; `test/lint/design_package_test.dart` exempts the generated
l10n paths for that reason. It is harmless because the file only needs `Locale`,
`BuildContext`, `Localizations` and `LocalizationsDelegate`, which the legacy library and
`material_ui` both re-export from the same `package:flutter/widgets.dart`. Everything a
human writes takes Material from `material_ui`; `cupertino_ui` is banned outright and
survives only as a transitive dependency of `material_ui`.
Strings live in `arb/intl_{en,zh_CN,ja,ru,fr,es,ko}.arb` — flat JSON, no `@` metadata. Add a key to all seven, then regenerate with
`dart run intl_utils:generate`, which rewrites `lib/l10n/`. A key present in only some locales silently falls back to
English at runtime, so add the translation rather than leaving it out.
Labels for enum values go through the exhaustive switches in `lib/common/l10n_labels.dart`, never
`Intl.message(<runtime string>)`. A key built from an enum name or a stored string is invisible to the analyzer, and a
failed lookup returns the key itself, so a stale key ships as `routeMode_config` in the UI rather than throwing.
`test/lint/dynamic_message_key_test.dart` fails on any `Intl.message(` in `lib` outside the generated code, and checks
that every locale carries the same keys and has a display name.
+147
View File
@@ -0,0 +1,147 @@
# Setting Changes
A changed setting takes one of three paths. The path depends on who reads the setting and when it reads it, not on
which page edits it. Choose the path when you add a setting or move one.
| Path | Who reads the setting | Watched provider | Acts | Effect |
|---------|---------------------------------------------|----------------------------------------|-------------------------------|--------------------------------------------|
| Update | Core, and Go can change it in place | `updateParamsProvider` | `CoreManager`, automatically | Patches the running config, no reload |
| Apply | Core, only when it parses a config | `setupPatchProvider` | `CoreManager`, automatically | Rebuilds the profile and reloads the Core |
| Restart | Android service, when it starts | `vpnOptionsProvider` | `VpnManager` asks the user | Stop and start of the service |
Update and apply run on their own, debounced. A restart never does: rebuilding the tunnel cuts every connection
through it, so `VpnManager` only shows a notifier and the user taps its restart action. A setting that only Flutter
reads takes no path.
## Update
`CoreManager` hands each change of `updateParamsProvider` to `SetupAction.updateConfigDebounce`. `updateConfig` then sends
`PatchClashConfig.toUpdateParams` to Go's `updateConfig` in `core/config.go`. That function writes the fields into the
running config and calls the runtime setter for each one, or `updateListeners` for the inbounds. Nothing is re-parsed.
The update path carries these fields:
- From `PatchClashConfig`: `mode`, `log-level`, `mixed-port`, `allow-lan`, `ipv6`, `find-process-mode`,
`tcp-concurrent`, `unified-delay`, `external-controller`, `secret`, `geo-auto-update`, `geo-update-interval`,
`geox-url`, and the `tun` keys of `Tun.coreJson`: `enable`, `device`, `stack`, and `auto-route`, always on.
- From other settings: `NetworkProps.authentication`, and `AppSettingProps.checkCertificate`, sent as
`skip-cert-verify`.
On desktop, `updateListeners` rebuilds the TUN from the patched `tun`. Enabling TUN may need authorization first. When
`requestAdmin` gets it newly granted, `updateConfig` restarts the Core instead of patching it. On Android,
`updateListeners` skips TUN, so a `tun` update reaches the VPN only through a restart.
To move a setting onto this path:
1. Add it to Dart `UpdateParams` and `toUpdateParams`.
2. Add it to Go `UpdateParams` in `core/protocol.go` and apply it in `updateConfig`.
3. Reset it in `PatchClashConfig.setupOnly`. If you skip this, every change also reloads the profile. The
`PatchClashConfig.setupOnly` test in `test/models/config_test.dart` fails until its fixture changes the field and
`setupOnly` resets it.
4. Keep writing it in `_makeRealProfileTask`, so a full apply hands the Core the same value.
Prefer update whenever Go has a setter for the setting, because an apply reloads the whole config.
## Apply
`CoreManager` answers each change of `setupPatchProvider` with `applyProfileDebounce(silence: true)`. That call
rebuilds the profile YAML and hands it to Go's `applyConfig` through `setupConfig`. The provider holds three values:
- `PatchClashConfig.setupOnly`: the config with every field that update carries reset to its default.
- `NetworkProps.appendSystemDns`.
- `NetworkProps.bypassPrivateRoute` on desktop, where the task adds the private ranges to the TUN's final
`route-exclude-address`.
A change to anything else in `PatchClashConfig` therefore reaches it. Today that is the DNS and TUN overrides (`dns`,
`dnsOverrideKeys`, `tunOverride`, `tunOverrideKeys`), `hosts`, the extra inbound ports (`port`, `socks-port`,
`redir-port`, `tproxy-port`), the dialer options (`keep-alive-*`, `disable-keep-alive`, `interface-name`, its mode,
`routing-mark`), `geodata-loader` and `global-ua`. A new `PatchClashConfig` field written by `_makeRealProfileTask` lands here without further work. A value
the profile build reads from another settings object has to be added to `setupPatchProvider`.
An apply without `force` sends nothing when the md5 of the generated YAML has not changed. A value that only
`SetupParams` carries never changes the YAML, so this path does not deliver it. `testUrl` is that case: Go's default
health-check URL follows it only when some other change or a `force` apply reaches the Core.
Profile content does not go through `setupPatchProvider`. `setupStateProvider` reads rules, groups and scripts from the
database once instead of watching them. Content is applied explicitly by whatever changes it:
- Profile editors call `autoApplyProfile` when they close.
- Subscription updates, scripts and app-level providers call `applyProfileDebounce`.
- A profile switch runs `fullSetup`.
- A start applies again with `force` unless the last apply saw the same inputs (`_inputsApplied`), the `testUrl` and
`skipCertVerify` that only `SetupParams` carries included.
## Restart
`VpnService.handleStart` builds the tunnel once from `VpnOptions` and never reads them again.
`ServiceController.start` uses `enable` to choose between `VpnService` and `ProxyService`.
`vpnOptionsProvider` derives the options the service would start with now. `runningVpnOptionsProvider` records what
the last listener start handed it. The route list is subtracted in an isolate by `vpnRouteAddressProvider`, which
reruns only when the route lists or `bypassPrivateRoute` change, so `vpnOptionsProvider` is async: a start awaits it
before recording, the Android Core awaits it before its first `syncState`, and the shared state keeps the last options
while new routes compute rather than handing the service an empty list. `SetupAction` owns every start and stop: it
sets the record on a start and clears it on a stop, on-demand SSID suspension included.
`VpnManager` shows the notifier whenever a change leaves `VpnOptions.effective` different from the recorded options.
Repeated changes refresh the same notifier instead of stacking new ones. Changing a setting back to the running value
asks for nothing. The tap runs `SetupAction.restartVpn`: a stop, then a start, and only while a run is up.
| `VpnOptions` field | Taken from | The service reads it |
|------------------------|---------------------------------------------------------------|----------------------------------------|
| `enable` | `VpnProps.enable`, off in safe mode | always |
| `stack` | `Tun.stack` | with the VPN |
| `ipv6`, `dnsHijacking` | `VpnProps` | with the VPN |
| `allowBypass` | `VpnProps` | with the VPN |
| `accessControlProps` | `VpnProps.accessControlProps` | `enable`, `mode` and that mode's list |
| `systemProxy` | `VpnProps.systemProxy`, off under local auth or safe mode | with the VPN |
| `port` | `PatchClashConfig.mixedPort` | with `systemProxy` |
| `bypassDomain` | `NetworkProps.bypassDomain` | with `systemProxy` |
| `routeAddress` | `Tun.routeAddress` and `bypassPrivateRoute` | with the VPN |
| `mtu` | `Tun` | with the VPN |
| `congestionController` | `Tun` | with the VPN |
The service never reads the access list's sort and filter fields. `effective` resets them, along with every field
the table does not mark as read, so changing them does not ask for a restart.
To add a VPN option:
1. Add it to Dart and Kotlin `VpnOptions` and to `vpnOptionsProvider`.
2. If the service reads it only under a condition, put that condition in `VpnOptions.effective`, which mirrors
`ServiceController.start` and `VpnService.handleStart`.
The notifier stays off while nothing runs, during on-demand suspension, in safe mode, and on desktop, where
`VpnManager` is not mounted. When Flutter reattaches to a service that is already running, it records the current
options, because the service does not report the options it was started with.
## One Setting, Several Paths
Each path watches its own provider, so a single setting can take more than one of them:
- `mixedPort`: update moves the Core's listener; under the VPN system proxy, the restart notice follows.
- TUN switch and stack: update rebuilds the desktop TUN. On Android the update reaches no TUN, and the restart notice
carries the stack.
- MTU, congestion control and routes: Android's rows take the restart path; on desktop they are TUN overrides, applied
with the profile.
- `bypassPrivateRoute`: apply on desktop, restart on Android.
- Local authentication: update applies it to the inbounds. It also switches the VPN system proxy, which needs a
restart.
## Choosing The Path
For a new or moved setting, answer each question. More than one answer can be yes.
1. Does the Android service read it when it starts? Then it belongs in `VpnOptions` and takes the restart path.
2. Does the Core read it, and can Go change it in place? Then it belongs in `UpdateParams` and takes the update path.
3. Does the Core read it only when it parses a config? Then write it into the generated config, and it takes the
apply path.
4. Does only Flutter read it? Then it takes no path.
Tests that hold these rules:
- `test/models/config_test.dart` (`PatchClashConfig.setupOnly`).
- `test/models/core_test.dart` (`VpnOptions.effective`).
- `test/providers/state_derived_test.dart` (the setup patch).
- `test/manager/core_manager_test.dart` (update against apply).
- `test/manager/vpn_manager_test.dart` (the restart notice).
- `test/providers/setup_action_test.dart` (running options, suspension, `restartVpn`).
+24
View File
@@ -0,0 +1,24 @@
# Agent Skills
Repo skills live under `.agents/skills/*/SKILL.md`, where Codex discovers them directly. Claude Code discovers them
through the symlinks in `.claude/skills/`, one per skill, pointing back at the `.agents/skills/` directory. Both tools
match a task against the skill `name` and `description`, then load the full instructions only when it matches.
## Available Repo Skills
- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.
- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, async feedback, and user-facing interactions.
- `core-platform`: Core lifecycle/process ownership, Android services, Go event delivery, desktop IPC, platform managers,
VPN/TUN, and Windows Helper flow.
- `pre-commit-quality-review`: lightweight triage before commits, with scoped structural review and user decisions on concrete regressions.
## Authoring Notes
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`, and add the matching
`.claude/skills/<skill-name>` symlink so Claude Code sees it too; remove both when retiring a skill.
- Git stores the symlinks as links only where the checkout supports them. A Windows checkout needs
`core.symlinks=true` and Developer Mode, otherwise the links become plain files and Claude Code loads no repo
skill there; Codex is unaffected because it reads `.agents/skills/` directly.
- Keep skill descriptions trigger-focused and start them with `Use when...`.
- Keep long reference material in `.agents/*.md`; skills should link to it instead of duplicating it.
- Put command permission rules in `.codex/rules/*.rules`, not in this file.
+65
View File
@@ -0,0 +1,65 @@
---
name: core-platform
description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, build hooks, system proxy, tray, VPN/TUN, or the Windows or Linux Helper.
---
# Core And Platform
## When To Use
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules,
build hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
## Read First
- `.agents/rules.md`: Core API Safety and Lifecycle Rules are binding. They carry the mihomo-fork patch budget, the
JSON envelope and `core/message.go` queue rules, gomobile callback rules, `BootGuard`, tray, and SSID rules, and every
lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,
optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).
- `.agents/setting-changes.md`: a setting the Core or the Android VPN reads is patched live, re-applied with the
profile, or left to a restart the user confirms; it says which, and what adding a setting to each path takes.
- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,
Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,
the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.
## Owners
Every behavior here has one authoritative owner, and a change lands there:
- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.
- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.
- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.
- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.
- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.
- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows and Linux Helper HTTP contract:
`lib/core/desktop/helper_client.dart` and `services/helper/`.
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; application exit:
`SystemExitCoordinator`; UI/event observation: `lib/manager/`.
An owner has more entry paths than the UI: provider calls, Quick Settings, notification actions, Always-on VPN, revoke
callbacks, application exit, and crash/disconnect recovery all reach it. Always-on startup is explicit through
`VPN_START_REQUESTED` and revoke through `VPN_REVOKED`; service creation or destruction is never read as intent.
## Verification
Focused tests live at the narrowest layer, and the matching commands are in `.agents/commands.md`:
- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.
- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.
- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.
- Android Kotlin: compile each touched Gradle module with JDK 17.
- Helper: `cargo fmt --check` and `cargo test`; the `windows-service` feature only builds on Windows.
State host gaps explicitly. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, polkit and systemd
on Linux, and Windows Service Control Manager behavior need their real platform even when portable tests pass.
## Pitfalls
- A `200` from Helper `/stop` means the Core is gone. When termination cannot be confirmed the Helper keeps the child
and answers `coreStopFailed`, and `/start` reports the same code instead of spawning a replacement. Keep that code
out of the Dart pre-spawn fallback set in `helper_client.dart`, or the direct launch races a Core the Helper still
owns.
- The Helper owns executable integrity: Core SHA256 validation and the protocol stay identical across Flutter build
modes, so a debug build is not exempt.
- `plugins/setup/` is a build harness, not a Dart API plugin, and its hooks can trigger Go or Rust compilation
indirectly through Flutter platform builds; a missing toolchain fails `flutter test`, not only `flutter build`.
+49
View File
@@ -0,0 +1,49 @@
---
name: localization
description: Use when changing FlClash UI text, scanning for hardcoded Chinese strings, updating ARB localization, or fixing generated locale output in this repository.
---
# Localization
## When To Use
Use this for localization work in this repository, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values.
Do not use this for README translation sync or manual edits to generated localization Dart.
## Workflow
1. Scan user-facing Dart text before opening many files. `flutter_intl` reads `arb/` and writes `lib/l10n/`:
```bash
rg -n "[\\p{Han}]" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'
```
2. Inspect the smallest relevant call sites and nearby ARB keys.
3. Add or update every source ARB:
- `arb/intl_en.arb`
- `arb/intl_zh_CN.arb`
- `arb/intl_ja.arb`
- `arb/intl_ru.arb`
- `arb/intl_fr.arb`
- `arb/intl_es.arb`
- `arb/intl_ko.arb`
4. Replace inline strings with existing project accessors:
- Widgets with `BuildContext`: `context.appLocalizations.key` from `lib/common/context.dart`.
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `lib/common/app_localizations.dart`.
5. Regenerate:
```bash
dart run intl_utils:generate
```
6. Run `flutter analyze --no-fatal-infos` and `flutter test test/l10n test/lint/dynamic_message_key_test.dart`. The
lint test checks that every locale carries the same keys and that no `Intl.message` builds a key at runtime; labels
for enum values go through the switches in `lib/common/l10n_labels.dart`, as the ARB section of `.agents/rules.md`
explains.
7. Re-run the Han-text scan for targeted strings.
## Pitfalls
- If ja/ru still show English, fix ja/ru source ARB values and regenerate. Do not edit generated Dart.
- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.
@@ -0,0 +1,105 @@
---
name: pre-commit-quality-review
description: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.
---
# Pre-commit Quality Review
Check that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,
so later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill
grants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is
not consent.
## Triage
Most commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a
repository-wide scan.
- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the
normal commit flow without extra confirmation. Say the review was skipped, never that it passed.
- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions
change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws
responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.
- An explicit structural review request bypasses triage.
The snapshot under review is the intended commit compared with its baseline:
| Operation | Baseline |
|---|---|
| Regular commit | `HEAD` |
| Amend | Parent of the commit being replaced |
| Squash | Version preceding the agreed range |
| Initial commit | Empty tree |
Ask when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of
partially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial
staging.
## Review the Final Structure
Scope the review to the responsibility boundaries this change touched. Read the complete final implementation of the
affected units and compare before and after, not only the last patch. Report issues this change introduced or
worsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a
concrete concern needs, and disclose missing essential context rather than claiming a complete pass.
Focus on:
- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled
with implementation detail; responsibilities in layers that should not own them.
- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate
nothing; similar names or interfaces hiding different responsibilities.
- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually
dependent helpers; names and decomposition that do not reduce reading effort.
- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,
scattered ownership of error handling, cleanup, or state updates.
- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite
invalidated; coupling that makes existing test boundaries hard to use.
Not findings: length, a missing design pattern, naming taste, or anything without code evidence and a real
maintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,
or demand abstractions for speculative needs.
Business logic, UI behavior, performance, security, platform compatibility, and full functional review are out of
scope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or
widening this review.
## Findings and the User's Decision
A finding qualifies only when all three hold:
- Before-and-after code and the necessary call relationships show this change introduced or worsened it.
- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one
responsibility. Clarity or elegance alone is not enough.
- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.
With no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the
existing commit authorization. Do not ask for confirmation because a review happened, and do not collect optional
improvements into a suggestion list.
With qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,
and give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the
smallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to
optimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the
user recognizes their configured workflow, for example: "The structural issues above support consolidating the
duplicate state-update paths. Should I make that change before committing?"
Existing explicit authorization for these findings is enough to act without asking again. The user may accept all,
some, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were
restaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.
## Resolve and Finish
Once authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task
scope; explain separately if either must change. Verify only the accepted findings and the direct effects of the
adjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold
and exceeds the existing authorization.
Before committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and
review only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,
`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse
results that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot
as verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the
commit.
Report the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.
Claim the commit is done only after it exists.
+144
View File
@@ -0,0 +1,144 @@
---
name: ui-work
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, icons or glyphs, shapes and corner radii, navigation surfaces, async feedback, or user-facing interactions.
---
# UI Work
## When To Use
Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows,
dialogs, and interaction behavior.
## Conventions
- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the
look the rest of the app has: Material You from `material_ui` as the base, with Apple's interaction conventions
(continuous corners, sheets that snap between detents, a floating tab bar) built on Material widgets rather than
Cupertino ones. Do not introduce a visual system for one screen or rewrite a layout the request does not need.
- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,
and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not
await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).
- A component that reads no provider, model or app string goes in `lib/ui/`; one built for an app feature goes in
`lib/widgets/`. The UI Layer section of `.agents/architecture.md` says how a `lib/ui` component gets app state.
- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows
section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.
- Every icon lives in `lib/ui/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it
with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled
button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the
fill animation, and when to fill.
- Text sizes and weights come from `AppTextStyle` in `lib/ui/typography.dart`, Apple's iOS Dynamic Type scale, never a
Material `TextTheme` slot, a bare `fontSize`, or a `fontWeight`; the Typography section of `.agents/rules.md` has the
rule.
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
keep scrims from stacking.
- A sheet's size belongs to its route, never to its content or call site: a bottom sheet rests at
`SheetProps.detents`, a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from
the window width (`lib/ui/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`), and a form sheet
is a card `FormSheetRoute` caps in width and height (`lib/ui/form_sheet.dart`). Do not wrap a sheet's content in a
fixed width or height to resize one sheet.
- A form with more than one value opens with `showFormSheet` and lays its values out in `FormRow`s, never as labeled
fields in a `CommonDialog`; a prompt for one value stays an alert. Forms in `.agents/architecture.md` has the rule.
- A modal scrolls only once the window and keyboard leave it no room, and then through one scroll view: `CommonDialog`
caps its body at nothing but the space it is given, and a multi-line field in a dialog or sheet grows with its text
(`maxLines: null`) rather than scrolling inside a line count. With the keyboard up, a bottom sheet keeps what it
shows above the keyboard, rising past its tallest detent as far as a peek below the top (`_top` in
`lib/ui/snap_sheet.dart`).
- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)
rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in
`.agents/architecture.md` has the reasons.
- A page loads what it shows as it mounts, never once its route has settled; what waits out a transition is work
that repeats, such as a live feed's refreshes (`RouteMotionHoldMixin`), and the rows past the viewport
(`arrivalScrollCacheExtent`). Page Arrival in `.agents/architecture.md` has the rule.
- A page that waits on its data shows nothing until all of it is ready (`NullStatusSwitcher.isLoading`), and a load
that can run long adds its illustration as `loadingStatus`, never a skeleton screen; Page Arrival in
`.agents/architecture.md` has the rule.
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
- A row's secondary actions open from a context menu, never a trailing more button; a list whose rows have one
reorders in a sort mode, any other by a long press on the row. Row Actions And Sort Mode in `.agents/architecture.md`
has the rule.
- Quick edit belongs only on a page that edits a whole list or section, or one isolated item; never on one that edits a
part of something other settings also write. Quick Edit in `.agents/architecture.md` has the rule.
- A page or sheet opens on the navigator of the context it comes from, never on the root navigator unless Where A
Route Opens in `.agents/architecture.md` allows it.
- User-facing text goes through ARB; use `localization` when text changes are non-trivial.
- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a
minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in
`finally` for animations and timers. `CoreStatusButton` and the proxies delay test action are the reference implementations,
described under User-Facing Core And Delay Feedback in `.agents/architecture.md`.
## Corner Radii
All corner radii come from `lib/ui/shape.dart`: a token, a derived value, or a named off-scale value passed to its
builders. Never write a bare radius literal at a call site.
The scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 dp tall
reads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.
| Token | Value | Shortest side | Use |
| --- | --- | --- | --- |
| `none` | 0 | - | square edges, and the flat side of a grouped run |
| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |
| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |
| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups |
| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |
| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |
| `xxl` | 28 | over 200 | sheets, full-screen containers |
| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |
- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`
builders; `AppShape` mirrors it as `RoundedSuperellipseBorder`, `full` included, plus `circle`, `input`, and the
`all`/`top`/`vertical`/`of` builders. Use `AppShape.full` for pills, never `StadiumBorder`; the Corner Radius
section of `.agents/rules.md` explains why and lists the superellipse API per layer.
- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever
the size comes from a `LayoutBuilder` or scales with text.
- `ThemeData.withAppShapes` in `lib/ui/shape.dart` already applies the scale to card, dialog, bottom sheet, snack
bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon
button themes. Do not restate those shapes at call sites, and
leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
- Colors of surfaces follow `.agents/surfaces.md`: fills by `SurfaceLevel`, no structural borders, and
`primaryContainer` only on a selected item.
`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its
corner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`
(`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy
group card in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the
one-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card in the tab view at
`ProxyCardType.compact` is only about 64 tall, which caps its radius at 21, so `xl` would break on a setting the user
can change at any time while `lg` clears both heights and the smallest dashboard tile.
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts
the floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8
up. `AppInputBorder` clips the notch region and paints the full superellipse through it, as Flutter's own
`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.
Nested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute
the outer value, as `_cardRadius` in `lib/ui/popup.dart` does. Adding a token for one of these is how a scale grows without
bound. A card that also stands on its own elsewhere keeps its token when nested: the proxy node cards inside a proxies
list group stay `lg` like those in the tab view, since deriving them from the group card's `xl` left them at 14, smaller
than every other card in the app.
A component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/ui/sidebar.dart` does with 10, between the
chip-sized `sm` and the near-pill `md` on a 40 tall row, and `_dialogCorner` in `lib/ui/shape.dart` does with 34, the
corner of an iOS alert. Reach for this only once the table and `fit()` are ruled out.
## Verification
- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,
including the exception path of any async action. Run `flutter analyze` and `flutter test test/widgets/` plus the
tests of the touched view.
- The scans in `test/lint/` fail on the common UI mistakes and run as part of `flutter test`: an `IconButton` whose
icon is an icon needs a `tooltip`; a field that constructs its own controller, notifier, or focus node is released in
the same file; a public declaration nothing references is dead. `.agents/rules.md` explains each rule and its
allowlist.
## Pitfalls
- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors
bypass them immediately.
- Do not leave loading animations active when callbacks throw.
- Do not manually edit generated localization or provider files.
+187
View File
@@ -0,0 +1,187 @@
# Surfaces
FlClash draws structure with fills and keeps color for state. A surface stands out from what it sits on by a step in
lightness, never by a border. Color appears where something is selected, on, failing, or the one action a screen is
for. The dashboard set this direction; every other screen follows it, and Material components keep their behavior
but give up their tonal container fills.
## Levels
`SurfaceLevel` in `lib/ui/surface.dart` names four depths. Each surface takes the level one deeper than the one
it sits on, which `SurfaceScope` passes down the tree.
| Level | Light | Dark | Holds |
|---------|---------------------------|---------------------------|---------------------------------------------------------|
| `page` | `surfaceContainer` | `surface` | scaffold body, sheets |
| `card` | `surfaceBright` | `surfaceContainer` | cards, grouped runs of rows, light-mode floats |
| `block` | `surfaceContainer` | `surfaceContainerHigh` | unselected segments, tiles and wells in a card |
| `inset` | `surfaceContainerHighest` | `surfaceContainerHighest` | small marks inside a block: glyph wells, badges, tracks |
Light mode alternates gray and paper: a near-white card on a gray page, a gray block in the card. The card is tone 98,
not pure white, and the page tone 94: pure white beside a tinted gray made the white glare and the gray look soiled. Dark mode only
lightens, because a darker step inside a lighter surface reads as a hole rather than a layer. `inset` is the floor;
nothing nests below it, so a design that needs a fifth level needs fewer containers instead.
- A widget never picks its level at the call site. `CommonCard` and the other shared surfaces read
`context.raisedSurfaceLevel` and wrap their child in `SurfaceScope`, so the same row is white on a page and gray
inside a card. A branch on `isInBottomSheet` or on where a widget is placed to choose its fill is the bug this
replaces.
- Sheets are `page` level, like a page, so a section looks the same in a sheet and in a page. In dark mode a sheet
presents a level up, as iOS presents a sheet on its elevated backgrounds: `ColorScheme.elevated` lifts every
surface role by the step from `page` to `card`, so the sheet's page takes the tone of the cards it covers and every
level inside it follows. Under pure black the sheet takes the plain dark scheme instead, which is the same step up
and keeps the seed's tint that a lift from black would lose. `ElevatedColorScheme` carries the result and the sheet
routes apply it through `ElevatedSurfaceTheme`; a sheet opened from a sheet keeps the same lift. A scrim barely
darkens a dark page, so a dark sheet at the page's own tone read as more of the page rather than a layer over it,
a covered sheet's peek vanished, and on black the edge and corners went with it. Light mode keeps its levels,
because the scrim already sets a light sheet apart.
- What floats over content, dialogs, menus, popups, the mobile dock, toasts, takes `SurfaceLevel.floatingOf`: `card`
in light, `block` in dark. A shadow does not show on a dark page, so a float has to come out lighter than the cards
it covers, and its content scopes to that level so a block inside still steps away from it.
- Shadows mark what floats, plus the dashboard cards. Cards in a scrolling list have none; a grid of proxies with a
blurred shadow each costs raster time for no extra layering. A render object that paints a shadow itself draws it
with `drawRSuperellipse`, as `ShapeDecoration` does: Impeller blurs a superellipse in one analytic pass, but a path
through an offscreen Gaussian blur on every frame, which made the shadows the largest raster cost of an opening menu.
## The Seeded Scheme
`seededColorScheme` in `lib/ui/color.dart` builds every app scheme: Material's scheme for the seed and the
`ColorStyle` picked in the theme settings, with three groups of roles replaced. Everything else stays Material,
including `toPureBlack` applied after it.
The four styles each change something a reader sees at once. Material's nine variants mostly moved secondary and
tertiary, which the app barely shows, so most of them looked alike once surfaces stayed gray.
| Style | Material variant | Primary | Surfaces |
|--------------|------------------|-------------------|---------------------------------|
| `vivid` | content | the seed | near gray |
| `tinted` | content | the seed | the seed's hue, up to chroma 10 |
| `soft` | tonal spot | Material's, muted | near gray |
| `monochrome` | monochrome | black or white | gray |
- **Surfaces stay near gray.** Each `surface*` role keeps Material's tone but its chroma is capped at 3, the light
`surface` and `surfaceBright` at 1.5, and `outlineVariant` at 4. Material tints neutrals by the seed (content
divides the seed's chroma by 8), so a yellow, green, or rose seed tinted the whole page. `tinted` is that wash
chosen on purpose and made even: every surface takes the seed's hue at a sixth of its chroma, capped at 10, so a
gray seed stays gray and any vivid one tints about as much.
- **`primaryContainer` comes from the primary palette.** It is primary's hue at tone 90 in light and 30 in dark,
with `onPrimaryContainer` at 30 and 90, and the pair's chroma is capped at 48. Content and fidelity otherwise put it
at the seed's own tone: a yellow seed filled it with pure yellow, a red seed with something that read as an error,
and dark mode with a light block under light text. The cap matters for light, chromatic hues: yellow at tone 90 is
still close to full strength.
- **`vivid` and `tinted` keep the seed as `primary`**, as an Apple accent color does: the color picked in the theme
settings is the color of every check, switch, and filled button. Light holds the seed's tone between 35 and 60, so
white text on it keeps about 3:1 and a near-black seed still reads as a color; dark lifts it 3 tones, as iOS lifts
its system colors, within 50 to 75, and puts dark text on it above 60. Material sets primary at tone 40 and 80 for
any seed, which turned iOS blue into navy in light and a pastel in dark. `soft` and `monochrome` keep Material's
primary, since moving away from the seed is what they are for.
## State
Color marks the one item that differs, never the structure around it.
- **Selected**, one of several rows, cards, or tiles: the item keeps its level's fill and a primary mark says which,
as iOS checks the option picked from a list. A row in a grouped run takes `SelectionCheck` at its edge; a card that
stands on its own, a profile or a proxy, and a tile take the filled `SelectionBadge`, which holds its own beside a
40 dp icon well where the bare check read as small and thin (`lib/ui/selection_mark.dart`). `CommonCard.isSelected` only reports the state to
accessibility. Fills were tried and rejected: a wash of primary landed on a gray that did not read as a choice, and
`primaryContainer` read as a warning under rose seeds in light and as a heavy slab in dark, muddied the translucent
marks on it, and cut a grouped run in two.
- **Multi-select** in edit mode: the circle checkbox is the mark; the row stays at its level.
- **Proxy groups** have two states in URLTest and Fallback: the check badge marks the proxy traffic goes through, in
every group type, and a pin badge (`SelectionBadge.preferred`) marks the preferred proxy (mihomo's `fixed`): filled
like the check while the group uses it, tonal while the group has fallen back to another proxy, which carries the
check.
- **Chosen segment** in a connected button group, a mode switch, or a run of compact choice chips
(`CommonCard.fillsSelection`): solid `primary` with `onPrimary`, as the dashboard outbound mode does. Only one
segment per group.
- **Error** on a row or card: `errorContainer` with `onErrorContainer`, no border.
- **Status** values (delay, availability, success, warning): the color goes on text, a glyph, or a small chip, never
on a whole card. `success` and `warning` come from fixed green and orange palettes that ignore the seed, like
Material's `error`; harmonizing pulled orange toward red under rose seeds until warning and error matched. Text takes
tone 40 in light and 80 in dark, a chip takes `successContainer`/`warningContainer` (90/30) with the `on` role
(30/90). A delay under 600 ms is success, above it warning, a failure error: `ColorScheme.delayColor`.
- **Destructive** action in an alert: `ColorScheme.destructive`, iOS 27's systemRed in both modes, not `error`. A
menu's `danger` item still takes `error`.
- **Data series** on one chart: the first is primary, the second primary at 45% over the card
(`DashboardColors.series2`), so they differ by lightness under any seed. Tertiary is not a series color.
- **Text**: titles and values `onSurface`, labels and secondary lines `onSurfaceVariant`. `outline` is not a text
color, and a value is never tinted primary.
- **Marks** that only identify, a country badge, a group or service icon, a source avatar, sit on a neutral well with
`onSurfaceVariant`.
- `primaryContainer`, `secondaryContainer`, and `tertiaryContainer` are neither surface nor selection fills. Keep
them out of cards, rows, marks, and navigation indicators.
## Borders That Stay
- **Focus**: a 2 dp primary stroke while a surface has keyboard or remote focus. TV navigation depends on it, and a
fill change alone is too faint to follow from a couch. A card, a grouped row above all, rings only focus a key moved
there (`FocusMovedByKey`), or any focus while a remote drives the app: a tap or a click puts focus where the reader
is already looking, a field's caret already marks it, and the ring then read as a selection box around the row.
- **Inputs**: a form's field is a bare `FormRowTextField` at the end of a grouped `FormRow` whose title labels it, as
iOS and macOS set one, with no stroke or fill of its own. A field among filled controls takes their fill, one level
above its surface, through `FilledInputTheme`. An alert's fields, because they sit over its pill buttons, as
UIAlertController sets them: as wide as the buttons, in their fill, with their text in line with the title and the
label as the placeholder (`CommonDialog.hasFields`); a field under a filled block of text in an alert takes the
block's width and corner instead. The custom color's hex field, because its card holds only fills, the color
previews, where an outline was the one stroked thing. Search fields are filled pills. A sheet's docked search,
because the list scrolls under it: it floats like the dock, with the floating fill and the dock's shadow, or the
dock's glass. The editor's find bar, because its fields sit in a row of filled pill buttons: they take the buttons'
fill, one level above the bar. All of these stroke only while focused. Any other field, one standing alone on a page,
is outlined, with the floating label notched into the stroke: `ThemeData.withAppShapes` gives every input
`AppShape.input`, so call sites leave `border` unset. A lone field is never filled: here a fill means structure, so
one read as one more card or row, and in light mode the hover overlay brought it down to the page's own tone. No
field counts its characters, as none does on iOS or macOS; a length limit holds through its formatter. Material
strokes a filled field's whole outline in `onSurfaceVariant`, which ringed the pill brighter than anything else in a
dark sheet.
- **Window chrome**: the desktop sidebar's inner edge and the title bar's bottom edge draw a divider where they meet
the content. The chrome fill alone left the edge to a four-tone step, which read as a smudge rather than a boundary.
Nothing else draws an outline. Rows in a grouped run are separated by the hairline from `lib/ui/list_row_separator.dart`, which divides
one surface and does not outline it. Record lists, requests, connections, logs, and DNS queries, are not grouped: they
run to thousands of tall rows, and one card that long reads as a slab rather than a group. A stacked row of three or
four lines is already its own block, so each is a card of its own, `RecordListItem`: the raised fill at `xl`, 16 dp
from the edges and 8 dp from the next, as the profile cards sit. Every card takes the same fill, so the warning and
error tints on a label still stand out, where a fill on every other row competed with them; rows on the page split by
a full-width divider were the one structural line left on screens otherwise drawn with fills. Laid out as a table,
where a row is one line read across many columns, they drop the gap and give every other row the raised fill, inset
and rounded like the column header, so the eye keeps its row; the dashboard's table does the same.
Every divider takes its color from `ThemeData.withAppSurfaces`: `onSurface` at 14%, never `outlineVariant`. A
translucent line keeps the same contrast on the page, a card, or the chrome, in both modes; `outlineVariant` stood out
twice as much on the dark page as on the light one and carried the seed's tint.
## Navigation
- The desktop sidebar and title bar are window chrome, `ColorScheme.chromeSurface`: `surfaceContainerHighest` in
light and `surfaceContainerLow` in dark, four tones from the page either way, with a divider along the edge that
meets the content. At `page` level the sidebar merged with the content. In dark the chrome stays below `card`, so
cards still stand out from it. The pane the sidebar opens over the content takes the same fill and edge, plus a
shadow.
The selected destination keeps its neutral fill and primary bar, which already marks it with color.
- The mobile dock and the docked navigation bar are floats; their indicator is a neutral platter one level above
them, and the selected destination's content takes primary, as iOS's tab bar marks its selection.
- With Liquid Glass on (Theme settings), the dock, the buttons of a page's bar, a sheet's docked search, popup
menus, and alerts are glass from `lib/ui/liquid_glass.dart` instead of a fill, and the dock's platter takes the
secondary hue and chroma at tone 90, or at tone 4 in dark, which sinks it below the bar as iOS 27 does.
- The proxies page's group tabs are compact choice chips, the current group in the chosen segment's solid `primary`.
Once they overflow, a tonal button at their end opens every group as a popup menu, the current one checked and
scrolled into view. A profile can carry dozens of groups, so the menu stops at seven rows and half of the next.
## Where It Lives
- `lib/ui/surface.dart`: `SurfaceLevel`, `SurfaceScope`, `context.surfaceLevel`, `context.raisedSurfaceLevel`,
`ColorScheme.chromeSurface`, and `ThemeData.withAppSurfaces`, applied next to `withAppShapes` wherever the app
builds its theme.
- `lib/ui/color.dart`: `seededColorScheme`, the status palettes, and `delayColor`.
- `lib/views/dashboard/style.dart`: `DashboardColors` maps `page`, `card`, `tone`, and `tone2` onto the levels and
adds the dashboard's switch, series, and status colors.
## Checking A Change
Render the touched screens in light and dark, on a phone width and a desktop width, before and after. A throwaway
widget test does it: pump `HomePage` or the view inside a `RepaintBoundary`, load Roboto with `FontLoader` so text
gets real glyphs, and write `RenderRepaintBoundary.toImage` to a PNG outside the repository. Look for a surface that
matches the one it sits on, which means it skipped `SurfaceScope`, and for a tonal container fill that survived on a
card or row.
+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.
+17
View File
@@ -0,0 +1,17 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PROJECT_DIR:-.}/tool/hooks/comment_density.sh\"",
"timeout": 15,
"statusMessage": "Checking comment density"
}
]
}
]
}
}
+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
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+8
View File
@@ -0,0 +1,8 @@
[[hooks.PostToolUse]]
matcher = "apply_patch|Bash"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "bash tool/hooks/comment_density.sh"
timeout = 15
statusMessage = "Checking comment density"
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp"
]
}
}
}
+2
View File
@@ -0,0 +1,2 @@
# freezed emits lines of trailing spaces, and generated code is never edited by hand.
lib/**/generated/** -whitespace
+57
View File
@@ -0,0 +1,57 @@
name: 问题反馈 / Bug report
title: "[BUG] "
description: 反馈你遇到的问题 / Report the issue you are experiencing
body:
- type: markdown
attributes:
value: |
## 在提交问题之前,请确认以下事项:
1. 请务必给issue填写一个简洁明了的标题,以便他人快速检索
2. 请确保[已有的问题](https://github.com/chen08209/FlClash/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论
3. 请务必按照模板规范详细描述问题,否则issue将会被直接关闭
## Before submitting the issue, please make sure of the following checklist:
1. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
2. Please make sure there is no similar issue in the [existing issues](https://github.com/chen08209/FlClash/issues?q=is%3Aissue), otherwise please discuss under the existing issue
3. Please describe the problem in detail according to the template specification, otherwise issue will be closed directly.
- type: textarea
id: description
attributes:
label: 问题描述 / Describe the bug
description: 详细清晰地描述你遇到的问题,并配合截图 / Describe the problem you encountered in detail and clearly, and provide screenshots
validations:
required: true
- type: textarea
attributes:
label: 软件版本 / Version
description: 请提供FlClash的具体版本 / Please provide the specific version of FlClash.
validations:
required: true
- type: textarea
attributes:
label: 复现步骤 / To Reproduce
description: 请提供复现问题的步骤 / Steps to reproduce the behavior
validations:
required: true
- type: dropdown
attributes:
label: 操作系统 / OS
options:
- Android
- Windows
- MacOS
- Linux
validations:
required: true
- type: input
attributes:
label: 操作系统版本 / OS Version
description: 请提供你的操作系统版本,Linux请额外提供桌面环境及窗口系统 / Please provide your OS version, for Linux, please also provide the desktop environment and window system
validations:
required: true
- type: textarea
attributes:
label: 日志(勿上传日志文件,请粘贴日志内容) / Log (Do not upload the log file, paste the log content directly)
description: 请提供完整或相关部分的Debug日志(请在“软件左侧菜单”->“设置”->“日志等级”调整到debug / Please provide a complete or relevant Debug log (please adjust it to debug in the left menu of software-> Settings-> Log Level)
validations:
required: true
+4
View File
@@ -0,0 +1,4 @@
contact_links:
- name: 讨论交流 / Communication
url: https://t.me/+G-veVtwBOl4wODc1
about: 在 Telegram 群组中与其他用户讨论交流 / Communicate with other users in the Telegram group
@@ -0,0 +1,42 @@
name: 功能请求 / Feature request
title: "[Feature] "
description: 提出你的功能请求 / Propose your feature request
body:
- type: markdown
attributes:
value: |
## 在提交问题之前,请确认以下事项:
1. 请务必给issue填写一个简洁明了的标题,以便他人快速检索
2. 请确保[已有的问题](https://github.com/chen08209/FlClash/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论
3. 请务必按照模板规范详细描述问题,否则issue将会被直接关闭
## Before submitting the issue, please make sure of the following checklist:
1. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
2. Please make sure there is no similar issue in the [existing issues](https://github.com/chen08209/FlClash/issues?q=is%3Aissue), otherwise please discuss under the existing issue
3. Please describe the problem in detail according to the template specification, otherwise issue will be closed directly.
- type: textarea
id: description
attributes:
label: 功能描述 / Feature description
description: 详细清晰地描述你的功能请求 / A clear and concise description of what the feature is
validations:
required: true
- type: textarea
attributes:
label: 使用场景 / Use case
description: 请描述你的功能请求的使用场景 / Please describe the use case of your feature request
validations:
required: true
- type: checkboxes
id: os-labels
attributes:
label: 适用系统 / Target OS
description: 请选择该功能适用的操作系统(至少选择一个) / Please select the operating system(s) for this feature request (select at least one)
options:
- label: Android
- label: Windows
- label: MacOS
- label: Linux
validations:
required: true
+41
View File
@@ -0,0 +1,41 @@
cask "flclash" do
version "VERSION"
on_macos do
arch arm: "arm64", intel: "amd64"
sha256 arm: "ARM_SHA256",
intel: "AMD_SHA256"
url "https://github.com/chen08209/FlClash/releases/download/v#{version}/FlClash-#{version}-macos-#{arch}.dmg"
end
name "FlClash"
desc "Multi-platform proxy client based on ClashMeta"
homepage "https://github.com/chen08209/FlClash"
livecheck do
url :url
strategy :github_latest
end
depends_on :macos
app "FlClash.app"
postflight_steps do
run "/usr/bin/xattr",
args: ["-rd", "com.apple.quarantine", "{{appdir}}/FlClash.app"],
writable_paths: ["FlClash.app"],
writable_base: :appdir
end
uninstall quit: "com.follow.clash"
zap trash: [
"~/Library/Application Support/com.follow.clash",
"~/Library/Caches/com.follow.clash",
"~/Library/Preferences/com.follow.clash.plist",
"~/Library/Saved Application State/com.follow.clash.savedState",
]
end
+4 -3
View File
@@ -31,9 +31,10 @@
</td>
</tr>
<tr>
<td>macOS (v10.15+)</td>
<td>macOS</td>
<td>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-macos-amd64.dmg"><img src="https://img.shields.io/badge/DMG-Universal-ea005e.svg?logo=apple"></a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-macos-arm64.dmg"><img src="https://img.shields.io/badge/DMG-Apple%20Silicon-%23000000.svg?logo=apple"></a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-macos-amd64.dmg"><img src="https://img.shields.io/badge/DMG-Intel%20X64-%2300A9E0.svg?logo=apple"></a><br>
</td>
</tr>
<tr>
@@ -41,7 +42,7 @@
<td>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.AppImage"><img src="https://img.shields.io/badge/AppImage-x64-f84e29.svg?logo=linux"> </a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.deb"><img src="https://img.shields.io/badge/DebPackage-x64-FF9966.svg?logo=debian"> </a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.deb"><img src="https://img.shields.io/badge/RpmPackage-x64-F1B42F.svg?logo=redhat"> </a>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.rpm"><img src="https://img.shields.io/badge/RpmPackage-x64-F1B42F.svg?logo=redhat"> </a>
</td>
</tr>
</tbody>
+469 -84
View File
@@ -2,11 +2,329 @@ name: build
on:
push:
branches:
- '**'
tags:
- 'v*'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }}
env:
IS_STABLE: ${{ !contains(github.ref, '-') }}
# See CI Caches in .agents/commands.md.
BRANCH_CACHE: ${{ !startsWith(github.ref, 'refs/tags/') }}
FLUTTER_VERSION: '3.47.6'
GO_VERSION: '1.27.1'
NDK_VERSION: r28c
jobs:
dart:
name: Dart
# A private copy pays for its Actions minutes, so it runs the checks only when
# started by hand.
if: &checks github.event_name == 'workflow_dispatch' || !github.event.repository.private
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Test git hooks
run: bash tool/hooks/test.sh
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: plugins/rust_api/rust
cache-directories: .dart_tool/hooks_runner
save-if: ${{ env.BRANCH_CACHE }}
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: ${{ env.BRANCH_CACHE }}
- name: Install dependencies
run: flutter pub get
- name: Check format
run: dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart
- name: Analyze
run: flutter analyze --no-fatal-infos
- name: Skip the Go core build hook
run: |
yq -i '.hooks.user_defines.setup.build_assets = false' pubspec.yaml
- name: Verify changelog
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
run: dart run tool/changelog.dart verify
- name: Run tests
run: dart run tool/test.dart -- --reporter expanded --coverage
- name: Check coverage
run: dart run tool/check_coverage.dart coverage/lcov.info 75
plugins:
name: Plugins
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: ${{ env.BRANCH_CACHE }}
- name: Validate local Flutter packages
run: bash tool/check_plugins.sh
- name: Validate setup hooks package
working-directory: plugins/setup/setup_hooks
run: |
dart pub get
dart analyze
dart test
desktop:
name: Desktop ${{ matrix.platform }}
if: (github.event_name == 'workflow_dispatch' || !github.event.repository.private) && !(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux
os: ubuntu-latest
- platform: windows
os: windows-2022
- platform: macos
os: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: |
core/go.sum
plugins/setup/setup_hooks/lib/src/go_builder.dart
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
key: desktop-${{ matrix.platform }}
workspaces: |
services/helper
plugins/rust_api/rust
cache-directories: .dart_tool/hooks_runner
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Linux build dependencies
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libsecret-1-dev
- name: Setup Xcode
if: matrix.platform == 'macos'
run: |
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
xcodebuild -version
- name: Enable git long paths (Windows)
if: matrix.platform == 'windows'
run: git config --global core.longpaths true
- name: Install dependencies
run: flutter pub get
# Compiles the desktop runners and the native plugin code that only the
# tagged release matrix built before; the bundle itself is discarded.
- name: Build debug bundle
run: flutter build ${{ matrix.platform }} --debug
go:
name: Go core
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: ${{ env.BRANCH_CACHE }}
cache-dependency-path: core/go.sum
- name: Validate Go core wrapper
working-directory: core
env:
CGO_ENABLED: 0
run: |
if ! compgen -G '*_test.go' > /dev/null; then
echo 'No Go tests found in core/; the test step would pass vacuously.' >&2
exit 1
fi
test -z "$(gofmt -l ./*.go ./cmd ./mobile ./platform ./tun)" || {
gofmt -l ./*.go ./cmd ./mobile ./platform ./tun >&2
exit 1
}
go test -count=1 .
go vet . ./cmd/...
GOOS=linux go vet ./platform
- name: Validate the Android core library
working-directory: core
env:
CGO_ENABLED: 0
run: |
tags="$(sed -n 's/^tags: *//p' ../build_config.yaml)"
test -n "$tags" || { echo 'Could not read tags from build_config.yaml' >&2; exit 1; }
GOOS=android GOARCH=arm64 go vet "-tags=$tags" . ./mobile ./tun ./platform
android:
name: Android unit tests
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: ${{ env.BRANCH_CACHE }}
cache-dependency-path: |
core/go.sum
android/core/build.gradle.kts
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: ${{ env.BRANCH_CACHE }}
- name: Install dependencies
run: flutter pub get
- name: Setup NDK
uses: nttld/setup-ndk@v1
id: ndk
with:
ndk-version: ${{ env.NDK_VERSION }}
link-to-sdk: true
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
- name: Run Android unit tests
working-directory: android
env:
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: |
{
echo "flutter.sdk=$FLUTTER_ROOT"
echo "sdk.dir=$ANDROID_HOME"
} > local.properties
wrapper="$FLUTTER_ROOT/bin/cache/artifacts/gradle_wrapper"
test -d "$wrapper" || flutter precache
cp -R "$wrapper/." .
chmod +x gradlew
# No JVM unit test loads the Core or the Flutter build, but `:core` compiles against
# the gomobile AAR, so one ABI of it is built.
./gradlew :common:testDebugUnitTest :service:testDebugUnitTest :app:testDebugUnitTest \
-Ptarget-platform=android-arm64 \
-x :app:compileFlutterBuildDebug -x :app:copyFlutterAssetsDebug
rust:
name: Rust
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: |
services/helper
plugins/rust_api/rust
save-if: ${{ env.BRANCH_CACHE }}
- name: Check format
run: |
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
cargo fmt --manifest-path plugins/rust_api/rust/Cargo.toml -- --check
- name: Run tests
run: |
cargo test --manifest-path services/helper/Cargo.toml
cargo test --manifest-path plugins/rust_api/rust/Cargo.toml
windows-helper-test:
name: Windows helper test
if: *checks
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: services/helper
save-if: ${{ env.BRANCH_CACHE }}
- name: Check format
run: cargo fmt --manifest-path services/helper/Cargo.toml -- --check
- name: Run Windows service tests
run: cargo test --manifest-path services/helper/Cargo.toml --features windows-service
build:
# Releases publish where the release links point: the public repository.
if: github.repository == 'chen08209/FlClash' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ${{ matrix.os }}
strategy:
matrix:
@@ -14,87 +332,116 @@ jobs:
- platform: android
os: ubuntu-latest
- platform: windows
os: windows-latest
os: Windows-2022
arch: amd64
- platform: linux
os: ubuntu-latest
os: ubuntu-22.04
arch: amd64
- platform: macos
os: macos-13
os: macos-15-intel
arch: amd64
- platform: macos
os: macos-latest
arch: arm64
- platform: windows
os: windows-11-arm
arch: arm64
flutter-channel: master
- platform: linux
os: ubuntu-24.04-arm
arch: arm64
flutter-channel: master
steps:
- name: Setup Mingw64
if: startsWith(matrix.platform,'windows')
uses: msys2/setup-msys2@v2
with:
msystem: mingw64
install: mingw-w64-x86_64-gcc
update: true
- name: Set Mingw64 Env
if: startsWith(matrix.platform,'windows')
- name: Setup rust
if: startsWith(matrix.os, 'windows-11-arm')
shell: pwsh
run: |
echo "${{ runner.temp }}\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Check Matrix
run: |
echo "Running on ${{ matrix.os }}"
echo "Arch: ${{ runner.arch }}"
gcc --version
Invoke-WebRequest -Uri "https://win.rustup.rs/aarch64" -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable
$cargoPath = "$env:USERPROFILE\.cargo\bin"
Add-Content $env:GITHUB_PATH $cargoPath
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup JAVA
if: startsWith(matrix.platform,'android')
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
- name: Setup NDK
if: startsWith(matrix.platform,'android')
uses: nttld/setup-ndk@v1
id: setup-ndk
with:
ndk-version: r26b
add-to-path: true
link-to-sdk: true
- name: Setup Android Signing
if: startsWith(matrix.platform,'android')
run: |
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/local.properties
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/local.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/local.properties
{
echo "keyAlias=${{ secrets.KEY_ALIAS }}"
echo "storePassword=${{ secrets.STORE_PASSWORD }}"
echo "keyPassword=${{ secrets.KEY_PASSWORD }}"
} >> android/local.properties
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: 'core/go.mod'
cache-dependency-path: |
core/go.sum
go-version: ${{ env.GO_VERSION }}
cache: ${{ env.BRANCH_CACHE }}
cache-dependency-path: core/go.sum
- name: Setup Gradle
if: startsWith(matrix.platform,'android')
uses: gradle/actions/setup-gradle@v6
- name: Enable git long paths (Windows)
if: startsWith(matrix.os, 'windows')
run: git config --global core.longpaths true
# Stable publishes no Windows or Linux arm64 archive; master makes the action
# clone the SDK at the pinned tag and let it fetch the host's own Dart SDK.
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.x'
channel: 'stable'
cache: true
channel: ${{ matrix.flutter-channel || 'stable' }}
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: ${{ env.BRANCH_CACHE }}
- name: Setup Xcode
if: startsWith(matrix.platform,'macos')
run: |
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
xcodebuild -version
- name: Setup NDK
if: startsWith(matrix.platform,'android')
uses: nttld/setup-ndk@v1
id: build-ndk
with:
ndk-version: ${{ env.NDK_VERSION }}
add-to-path: true
link-to-sdk: true
- name: Get Flutter Dependency
run: flutter pub get
run: |
flutter --version
flutter pub get
# See .agents/flutter-upgrade.md before bumping FLUTTER_VERSION.
- name: Disable Impeller on Intel macOS
if: matrix.platform == 'macos' && matrix.arch == 'amd64'
run: plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist
# The core embeds this bundle and FlClashHttpOverrides.trustCoreRoots loads it for Dart.
# Pinned by hash, so a new bundle from https://curl.se/docs/caextract.html is a reviewed bump.
- name: Fetch CA certificates
shell: bash
env:
CA_BUNDLE: cacert-2026-09-25.pem
CA_BUNDLE_SHA256: a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505
run: |
bundle=core/Clash.Meta/component/ca/ca-certificates.crt
curl -fsSL --retry 3 -o "$bundle" "https://curl.se/ca/$CA_BUNDLE"
echo "$CA_BUNDLE_SHA256 $bundle" | sha256sum -c -
- name: Setup
run: dart setup.dart ${{ matrix.platform }} ${{ matrix.arch && format('--arch {0}', matrix.arch) }}
env:
ANDROID_NDK_HOME: ${{ steps.build-ndk.outputs.ndk-path }}
run: dart setup.dart ${{ matrix.platform }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }} -v
- name: Upload
uses: actions/upload-artifact@v4
@@ -103,10 +450,10 @@ jobs:
path: ./dist
overwrite: true
upload:
permissions: write-all
needs: [ build ]
permissions:
contents: write
needs: [ build, dart, plugins, go, android, rust, windows-helper-test ]
runs-on: ubuntu-latest
services:
telegram-bot-api:
@@ -129,63 +476,101 @@ jobs:
pattern: artifact-*
merge-multiple: true
- name: Generate release.md
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: ${{ env.BRANCH_CACHE }}
- name: Install dependencies
run: flutter pub get
- name: Skip native build hooks
run: |
tags=($(git tag --merged $(git rev-parse HEAD) --sort=-creatordate))
preTag=$(curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" | grep -Po '"tag_name": "\K.*?(?=")' || echo "")
currentTag=""
for ((i = 0; i <= ${#tags[@]}; i++)); do
if (( i < ${#tags[@]} )); then
tag=${tags[$i]}
else
tag=""
fi
if [ -n "$currentTag" ]; then
if [ "$(echo -e "$currentTag\n$preTag" | sort -V | head -n 1)" == "$currentTag" ]; then
break
fi
fi
if [ -n "$currentTag" ]; then
if [ -n "$tag" ]; then
git log --pretty=format:"%B" "$tag..$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> release.md
else
git log --pretty=format:"%B" "$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> release.md
fi
echo "" >> release.md
fi
currentTag=$tag
done
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false' pubspec.yaml
- name: Generate release notes
shell: bash
env:
IS_STABLE: ${{ env.IS_STABLE }}
TAG: ${{ github.ref_name }}
run: |
target=()
if [[ "$IS_STABLE" == 'true' ]]; then
target=(--tag "$TAG")
else
dart run tool/changelog.dart build --unreleased
fi
dart run tool/changelog.dart render release "${target[@]}" --out release.md
dart run tool/changelog.dart render telegram "${target[@]}" --out telegram.md
- name: Push to telegram
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TAG: ${{ github.ref_name }}
RUN_ID: ${{ github.run_id }}
run: |
python -m pip install --upgrade pip
pip install requests
python release.py
python release_telegram.py
- name: Patch release.md
run: |
version=$(echo "${{ github.ref_name }}" | sed 's/^v//')
sed "s|VERSION|$version|g" ./.github/release_template.md >> release.md
- name: Generate sha256
if: ${{ env.IS_STABLE == 'true' }}
run: |
cd ./dist
sha256sum ./* > SHA256SUMS
- name: Release
if: ${{ !contains(github.ref, '+') }}
if: ${{ env.IS_STABLE == 'true' }}
uses: softprops/action-gh-release@v2
with:
files: ./dist/*
body_path: './release.md'
- name: Create Homebrew Cask Source Dir
if: ${{ env.IS_STABLE == 'true' }}
run: |
version=$(echo "${{ github.ref_name }}" | sed 's/^v//')
arm_sha=$(sha256sum "./dist/FlClash-${version}-macos-arm64.dmg" | awk '{print $1}')
amd_sha=$(sha256sum "./dist/FlClash-${version}-macos-amd64.dmg" | awk '{print $1}')
mkdir -p ./brew/Casks
sed \
-e "s|VERSION|$version|g" \
-e "s|ARM_SHA256|$arm_sha|g" \
-e "s|AMD_SHA256|$amd_sha|g" \
./.github/homebrew_cask_template.rb > ./brew/Casks/flclash.rb
- name: Push to Homebrew tap
if: ${{ env.IS_STABLE == 'true' }}
uses: cpina/github-action-push-to-another-repository@v1.7.2
env:
SSH_DEPLOY_KEY: ${{ secrets.HOMEBREW_SSH_DEPLOY_KEY }}
with:
source-directory: ./brew/
destination-github-username: chen08209
destination-repository-name: homebrew-tap
user-name: 'chen08029'
user-email: 'chen08209@gmail.com'
target-branch: main
commit-message: Update Homebrew cask from ${{ github.ref_name }}
target-directory: /
- name: Create Fdroid Source Dir
if: ${{ !contains(github.ref, '+') }}
if: ${{ env.IS_STABLE == 'true' }}
run: |
mkdir -p ./tmp
cp ./dist/*android-arm64-v8a* ./tmp/ || true
echo "Files copied successfully"
- name: Push to fdroid repo
if: ${{ !contains(github.ref, '+') }}
if: ${{ env.IS_STABLE == 'true' }}
uses: cpina/github-action-push-to-another-repository@v1.7.2
env:
SSH_DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
@@ -193,8 +578,8 @@ jobs:
source-directory: ./tmp/
destination-github-username: chen08209
destination-repository-name: FlClash-fdroid-repo
user-name: 'github-actions[bot]'
user-email: 'github-actions[bot]@users.noreply.github.com'
target-branch: action-pr
user-name: 'chen08029'
user-email: 'chen08209@gmail.com'
target-branch: main
commit-message: Update from ${{ github.ref_name }}
target-directory: /tmp/
target-directory: /tmp/
-45
View File
@@ -1,45 +0,0 @@
name: change
on:
push:
branches:
- 'main'
jobs:
changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate
run: |
tags=$(git tag --sort=creatordate)
previous=""
if [ ! -f CHANGELOG.md ]; then
echo "" > CHANGELOG.md
else
previous=$(grep -oP '^## \K.*' CHANGELOG.md | tail -n 1)
fi
for tag in $tags; do
if [ -n "$previous" ]; then
echo "## $tag" >> CHANGELOG.md
git log --pretty=format:"* %s (%h)" "$previous..$tag" >> CHANGELOG.md
echo -e "\n" >> CHANGELOG.md
fi
previous=$tag
done
- name: Commit
run: |
if !git diff --cached --quiet; then
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add CHANGELOG.md
git commit -m "Update Changelog"
git push
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_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
+33 -6
View File
@@ -5,9 +5,11 @@
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
@@ -19,7 +21,7 @@ migrate_working_dir/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
.vscode/
# Flutter/Dart/Pub related
**/doc/api/
@@ -39,15 +41,40 @@ app.*.symbols
# Obfuscation related
app.*.map.json
# Claude Code - ignore user-specific settings only
.claude/settings.local.json
.claude/worktrees/
# Documentation (generated/planning artifacts)
docs/
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
/android/**/.cxx
/android/**/build
/android/common/**/.**/
/android/common/local.*
# Left in place by builds from before the gomobile Core.
/android/core/**/includes/
/android/core/**/cmake-build-*/
/android/core/**/jniLibs/
#libclash
#FlClash
/libclash/
#jniLibs
/android/app/src/main/jniLibs/
/services/helper/target
/macos/**/Package.resolved
/macos/build/
/env.json
/core_sha256.json
/plugins/rust_api/.claude/
/issue-triage/
devtools_options.yaml
# FVM Version Cache
.fvm/
.fvmrc
/coverage/lcov.info
-4
View File
@@ -1,8 +1,4 @@
[submodule "core/Clash.Meta"]
path = core/Clash.Meta
url = git@github.com:chen08209/Clash.Meta.git
branch = FlClash-Alpha
[submodule "plugins/flutter_distributor"]
path = plugins/flutter_distributor
url = git@github.com:chen08209/flutter_distributor.git
branch = FlClash
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp"
]
}
}
}
+7 -22
View File
@@ -1,11 +1,11 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled.
# This file should be version controlled and should not be manually edited.
version:
revision: 796c8ef79279f9c774545b3771238c3098dbefab
channel: stable
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
channel: "stable"
project_type: app
@@ -13,26 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: android
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: ios
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: linux
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: macos
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: web
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
- platform: windows
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
# User provided section
+26
View File
@@ -0,0 +1,26 @@
repos:
- repo: local
hooks:
- id: dart-format
name: dart format
entry: dart format
language: system
files: \.dart$
stages: [pre-commit]
- id: comment-density
name: comment density
entry: tool/hooks/comment_density.sh
language: system
files: \.(dart|kt|kts|swift|go|rs|java|cpp|cc|h|hpp|gradle|ya?ml)$
stages: [pre-commit]
- id: flutter-analyze
name: flutter analyze (pre-push)
entry: flutter analyze --no-fatal-infos
language: system
pass_filenames: false
stages: [pre-push]
- id: commit-msg-format
name: conventional commit message
entry: tool/hooks/commit_msg.sh
language: system
stages: [commit-msg]
+7
View File
@@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="main.dart" type="FlutterRunConfigurationType" factoryName="Flutter">
<option name="additionalArgs" value="--dart-define-from-file env.json" />
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
<method v="2" />
</configuration>
</component>
+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",
],
},
]
+13
View File
@@ -0,0 +1,13 @@
{
"lsp": {
"gopls": {
"binary": {
"env": {
"CGO_ENABLED": "0",
// "GOOS": "android",
// "GOARCH": "arm64",
},
},
},
},
}
+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,
},
]
+77
View File
@@ -0,0 +1,77 @@
# AGENTS.md
This file is the entry point for AI coding agents working in this repository. Keep it small: detailed guidance lives under
`.agents/`, and discoverable repo skills live under `.agents/skills/*/SKILL.md`.
## Start Here
Read these files before making changes:
- [.agents/project.md](.agents/project.md): project overview, versions, and build dependencies.
- [.agents/commands.md](.agents/commands.md): build, development, code generation, and test commands.
- [.agents/rules.md](.agents/rules.md): lint, testing, generated-code, and workflow rules.
Read these only when the task touches their area:
- [.agents/architecture.md](.agents/architecture.md): core integration, providers, database, managers, build system, and
local plugins.
- [.agents/setting-changes.md](.agents/setting-changes.md): whether a changed setting is patched live, re-applies the
profile, or asks for an Android VPN restart.
- [.agents/surfaces.md](.agents/surfaces.md): fill levels, selection and status colors, and the few borders that
remain, for any change to how a surface is colored.
- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,
command rules, and hooks.
- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.
- [.agents/flutter-upgrade.md](.agents/flutter-upgrade.md): SDK-pinned workarounds to recheck before changing the
Flutter version.
- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.
## Highest Priority Rules
- When the user explicitly requests a scoped, low-risk change, inspect the relevant context and implement it directly.
Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation.
Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result.
- The request sets the scope. A pre-existing bug, cleanup, or refactor the task did not ask for is a follow-up in the
summary, not part of the change. Edit a file surgically when a rewrite would give the same result, keep scratch checks
out of the repository, and commit tests only where the task asks for them or the neighbouring code already keeps them.
- Comments are allowed and sometimes necessary; excess is the problem. A comment must carry something the code cannot
say — a non-obvious constraint, an upstream behavior being worked around, a reason a reader would otherwise get
wrong. Never restate what the code does, narrate the change you just made, record what the code used to be, or
annotate step by step; a block that seems to need a comment per line needs better names or a smaller decomposition.
Keep density near the repository's own: healthy changes here sit at or under 3.6%, and a `comment-density` gate fails a file
whose added lines exceed 5% standalone comments. Delete commented-out code and stale notes in files you already
touch. Preserve
`// ignore:`-style directives, license headers, codegen markers, and vendored upstream comments. See
[.agents/rules.md](.agents/rules.md) for what belongs in a test or in `.agents/` instead.
- Start FlClash on the host only as a safe mode build, and end only processes you started, by recorded pid. Never
kill a FlClash instance by name; the user's own instance is not yours to close. See
[.agents/rules.md](.agents/rules.md).
- Use `flutter test`, not `dart test`, because models pull in Flutter types.
- Run code generation after modifying models, providers, or database schema.
- Do not manually edit generated files.
- Preserve lifecycle ownership: desktop Core process convergence belongs to `lib/core/desktop/`; Android service intent
arbitration belongs to `ServiceState`. UI/provider code may request a transition but must not become a second source of
truth.
- Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while
native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded
requests.
- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to.
The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules.
- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/ui/shape.dart`,
never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or
`InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of
[.agents/rules.md](.agents/rules.md).
- Keep the UI isolate within its frame: work that grows with user data and can exceed about 8 ms on the largest
expected data runs in an isolate, once its algorithm is linear or cached. See the UI Isolate Work section of
[.agents/rules.md](.agents/rules.md).
- Follow `lint_options.yaml` (included by every `analysis_options.yaml`), especially single quotes, trailing commas, `child:` last, no `print()`, const/final
preferences, and declared return types.
- Before reporting a change done, run `flutter pub get`, `flutter analyze --no-fatal-infos`, and the `flutter test`
suites that cover it, as CI does. If a toolchain the build hook needs is missing (Go, and on Linux and Windows
cargo), say which check was skipped rather than dropping it silently.
## Repo Skills
Use repo skills from `.agents/skills/` when a task matches their descriptions; `.claude/skills/` symlinks expose the
same skills to Claude Code. Current skills cover localization, UI work, core/platform changes, and pre-commit
structural quality review.
+397 -248
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
SHELL := /bin/bash
.PHONY: help submodules
help:
@echo 'make submodules # update git submodules (Clash.Meta core sources)'
@echo ''
@echo 'The Go core and Rust helper build automatically through the setup build'
@echo 'hook during flutter build; see .agents/commands.md.'
submodules:
git submodule update --init --recursive
+102 -68
View File
@@ -1,104 +1,138 @@
<div>
<div align="center">
[**简体中文**](README_zh_CN.md)
<img src="assets/images/icon.png" alt="FlClash" width="88">
# FlClash
A multi-platform proxy client based on ClashMeta. Simple to use, open source and ad-free.
**English** · [简体中文](README_zh_CN.md)
[![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
- **One app for Android, Windows, macOS and Linux**, with x64 and ARM64 builds on the desktop.
- **mihomo (Clash.Meta) core** with rule routing, proxy groups, latency tests, system proxy and TUN mode.
- **Profiles** from a subscription link or a file, with a built-in editor, override scripts, and custom rules,
proxies and proxy groups.
- **Live views** of connections, requests, DNS queries and logs.
- **Material You design with Apple-style interaction**: dynamic color, light and dark themes, continuous corners,
sheets that snap between heights, and layouts that adapt from phones to desktops.
- **Backup and restore** through WebDAV or a local file.
- **Platform touches**: a Quick Settings tile, per-app proxy and Android TV support on Android; a tray menu and
global hotkeys on the desktop.
- **Open source and ad-free**, licensed under GPL-3.0.
## Download
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
Get the latest build from [GitHub Releases](https://github.com/chen08209/FlClash/releases/latest), or open the
[website](https://chen08209.github.io/FlClash#download), which picks the right file for your device.
## Build
| Platform | Packages | Notes |
| --- | --- | --- |
| Android | APK for `arm64-v8a`, `armeabi-v7a` and `x86_64` | Most phones use `arm64-v8a`. Also on the F-Droid repository below. |
| Windows 10 and later | Installer (`.exe`) or portable `.zip`, for x64 and ARM64 | Pick ARM64 on Snapdragon and other ARM laptops. |
| macOS 12 and later | DMG for Apple Silicon and Intel | Also on Homebrew. |
| Linux | `.deb`, `.rpm` and AppImage, for x64 and ARM64 | See the tray note below. |
1. Update submodules
```bash
git submodule update --init --recursive
```
<p>
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" height="56"></a>
<a href="https://github.com/chen08209/FlClash/releases/latest"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" height="56"></a>
</p>
2. Install `Flutter` and `Golang` environment
**Homebrew**
3. Build Application
```bash
brew tap chen08209/tap
brew install --cask flclash
```
- android
**Linux tray icon**
1. Install `Android SDK` , `Android NDK`
The `.deb` package installs its own dependencies. With the AppImage or the `.rpm`, install the AyatanaAppIndicator
library so the tray icon can show:
2. Set `ANDROID_NDK` environment variables
```bash
sudo apt-get install libayatana-appindicator3-1 # Debian and Ubuntu
sudo dnf install libayatana-appindicator-gtk3 # Fedora
```
3. Run Build script
## Usage
```bash
dart .\setup.dart android
```
**Import a profile from a link.** Opening a link in this form imports the subscription into FlClash. The `clashmeta://`
and `flclash://` schemes work the same way.
- windows
```text
clash://install-config?url=<URL-encoded subscription link>
```
1. You need a windows client
**Automate on Android.** Tasker, MacroDroid and similar apps can start, stop or toggle the proxy by starting an
activity with one of these actions:
2. Install `Gcc`,`Inno Setup`
```text
com.follow.clash.action.START
com.follow.clash.action.STOP
com.follow.clash.action.TOGGLE
```
3. Run build script
From a computer, the same works over adb:
```bash
dart .\setup.dart
```
```bash
adb shell am start -a com.follow.clash.action.TOGGLE
```
- linux
## Build from source
1. You need a linux client
You need [Flutter](https://docs.flutter.dev/get-started/install) 3.47 (release builds use 3.47.6),
[Go](https://go.dev/dl/) 1.27 and [Rust](https://rustup.rs/) installed through rustup. Each platform builds on its own
host, except Android, which builds anywhere.
2. Run build script
```bash
git clone --recursive https://github.com/chen08209/FlClash.git
cd FlClash
flutter pub get
dart setup.dart android # or windows, macos, linux
```
```bash
dart .\setup.dart
```
Packages land in `dist/`. The Go core and the Rust libraries are compiled as part of the Flutter build.
- macOS
| Platform | Also needed |
| --- | --- |
| Android | Android SDK with the NDK. Add `--arch arm64` to build a single ABI. |
| Windows | GCC (MinGW-w64) for the core and [Inno Setup](https://jrsoftware.org/isinfo.php) 6 for the installer. |
| macOS | Xcode and Node.js. The script installs `appdmg` through npm. |
| Linux | Debian or Ubuntu. The script installs the build packages with apt and downloads `appimagetool`. |
1. You need a macOS client
Run `dart setup.dart --help` for the remaining options, such as `--targets` to build only some package formats.
2. Run build script
## Support
```bash
dart .\setup.dart
```
Starring the repository is the easiest way to support the project. Questions and announcements go to the
[Telegram channel](https://t.me/FlClash); bugs and feature requests go to
[GitHub Issues](https://github.com/chen08209/FlClash/issues).
## Star
<a href="https://star-history.com/#chen08209/FlClash&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date&theme=dark">
<img alt="Star history" src="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date" width="640">
</picture>
</a>
The easiest way to support developers is to click on the star (⭐) at the top of the page.
## License
<p style="text-align: center;">
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
</a>
</p>
FlClash is released under the [GPL-3.0 license](LICENSE).
+100 -75
View File
@@ -1,105 +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 设计,融合 Apple 式交互**:支持动态取色、浅色与深色主题,连续曲率圆角,弹出面板可在几档高度间吸附,布局随屏幕从手机到桌面自适应。
- **备份与恢复**:通过 WebDAV 或本地文件。
- **平台细节**:Android 上有快捷设置磁贴、分应用代理和 Android TV 支持;桌面端有托盘菜单和全局快捷键。
- **开源无广告**,以 GPL-3.0 协议发布。
## 下载
从 [GitHub Releases](https://github.com/chen08209/FlClash/releases/latest) 获取最新版本,或打开
[官网](https://chen08209.github.io/FlClash/zh#download),它会自动为你的设备选好安装包。
| 平台 | 安装包 | 说明 |
| --- | --- | --- |
| Android | `arm64-v8a`、`armeabi-v7a`、`x86_64` 三种 APK | 绝大多数手机选 `arm64-v8a`。也可以通过下方的 F-Droid 仓库安装。 |
| Windows 10 及以上 | 安装版(`.exe`)或便携版(`.zip`),分 x64 与 ARM64 | 骁龙等 ARM 架构笔记本选 ARM64。 |
| macOS 12 及以上 | Apple Silicon 与 Intel 两种 DMG | 也可以通过 Homebrew 安装。 |
| Linux | `.deb`、`.rpm` 和 AppImage,分 x64 与 ARM64 | 托盘依赖见下方说明。 |
<p>
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" height="56"></a>
<a href="https://github.com/chen08209/FlClash/releases/latest"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" height="56"></a>
</p>
## Features
**Homebrew**
✈️ 多平台: Android, Windows, macOS and Linux
```bash
brew tap chen08209/tap
brew install --cask flclash
```
💻 自适应多个屏幕尺寸,多种颜色主题可供选择
**Linux 托盘图标**
💡 基本 Material You 设计, 类[Surfboard](https://github.com/getsurfboard/surfboard)用户界面
`.deb` 安装包会自动安装所需依赖。使用 AppImage 或 `.rpm` 时,需要先安装 AyatanaAppIndicator 库,托盘图标才能显示:
☁️ 支持通过WebDAV同步数据
```bash
sudo apt-get install libayatana-appindicator3-1 # Debian 与 Ubuntu
sudo dnf install libayatana-appindicator-gtk3 # Fedora
```
✨ 支持一键导入订阅, 深色模式
## 使用
## Download
**通过链接导入配置。** 打开下面格式的链接,即可把订阅导入 FlClash。`clashmeta://` 和 `flclash://` 开头的链接同样可用。
<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>
```text
clash://install-config?url=<经过 URL 编码的订阅链接>
```
## Build
**Android 自动化。** Tasker、MacroDroid 等应用可以用下面的 Action 启动 Activity,从而启动、停止或切换代理:
1. 更新 submodules
```bash
git submodule update --init --recursive
```
```text
com.follow.clash.action.START
com.follow.clash.action.STOP
com.follow.clash.action.TOGGLE
```
2. 安装 `Flutter` 以及 `Golang` 环境
在电脑上也可以通过 adb 触发:
3. 构建应用
```bash
adb shell am start -a com.follow.clash.action.TOGGLE
```
- android
## 从源码构建
1. 安装 `Android SDK` , `Android NDK`
需要 [Flutter](https://docs.flutter.dev/get-started/install) 3.47(正式版构建使用 3.47.6)、[Go](https://go.dev/dl/) 1.27,
以及通过 rustup 安装的 [Rust](https://rustup.rs/)。桌面端需要在对应系统上构建,Android 在任意系统上都能构建。
2. 设置 `ANDROID_NDK` 环境变量
```bash
git clone --recursive https://github.com/chen08209/FlClash.git
cd FlClash
flutter pub get
dart setup.dart android # 或 windows、macos、linux
```
3. 运行构建脚本
安装包输出到 `dist/`。Go 内核和 Rust 库会在 Flutter 构建过程中一并编译。
```bash
dart .\setup.dart android
```
| 平台 | 额外依赖 |
| --- | --- |
| Android | 带 NDK 的 Android SDK。加上 `--arch arm64` 可以只构建单个 ABI。 |
| Windows | 编译内核用的 GCC(MinGW-w64),以及打包安装程序用的 [Inno Setup](https://jrsoftware.org/isinfo.php) 6。 |
| macOS | Xcode 与 Node.js。脚本会通过 npm 安装 `appdmg`。 |
| Linux | Debian 或 Ubuntu。脚本会用 apt 安装构建所需的软件包,并下载 `appimagetool`。 |
- windows
其余选项可以运行 `dart setup.dart --help` 查看,例如用 `--targets` 只构建部分安装包格式。
1. 你需要一个windows客户端
## 支持
2. 安装 `Gcc`,`Inno Setup`
给仓库点一个 Star 是支持项目最简单的方式。问题讨论和更新通知请关注 [Telegram 频道](https://t.me/FlClash),
Bug 与功能建议请提交到 [GitHub Issues](https://github.com/chen08209/FlClash/issues)。
3. 运行构建脚本
<a href="https://star-history.com/#chen08209/FlClash&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date&theme=dark">
<img alt="Star history" src="https://api.star-history.com/svg?repos=chen08209/FlClash&type=Date" width="640">
</picture>
</a>
```bash
dart .\setup.dart
```
## 许可证
- linux
1. 你需要一个linux客户端
2. 运行构建脚本
```bash
dart .\setup.dart
```
- macOS
1. 你需要一个macOS客户端
2. 运行构建脚本
```bash
dart .\setup.dart
```
## Star History
支持开发者的最简单方式是点击页面顶部的星标(⭐)。
<p style="text-align: center;">
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
</a>
</p>
FlClash 以 [GPL-3.0 协议](LICENSE)发布。
+15 -28
View File
@@ -1,29 +1,16 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
include: lint_options.yaml
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at
# https://dart-lang.github.io/linter/lints/index.html.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
analyzer:
exclude:
- build/**
- lib/l10n/intl/**
- lib/**/generated/**
- plugins/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
errors:
invalid_annotation_target: ignore
-114
View File
@@ -1,114 +0,0 @@
import com.android.build.gradle.tasks.MergeSourceSetFolders
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
def defStoreFile = file("keystore.jks")
def defStorePassword = localProperties.getProperty('storePassword')
def defKeyAlias = localProperties.getProperty('keyAlias')
def defKeyPassword = localProperties.getProperty('keyPassword')
def isRelease = defStoreFile.exists() && defStorePassword != null && defKeyAlias != null && defKeyPassword != null
android {
namespace "com.follow.clash"
compileSdkVersion 34
ndkVersion "27.1.12297006"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
signingConfigs {
if (isRelease) {
release {
storeFile defStoreFile
storePassword defStorePassword
keyAlias defKeyAlias
keyPassword defKeyPassword
}
}
}
defaultConfig {
applicationId "com.follow.clash"
minSdkVersion 21
targetSdkVersion 34
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
debug {
minifyEnabled false
applicationIdSuffix '.debug'
}
release {
if (isRelease) {
signingConfig signingConfigs.release
} else {
signingConfig signingConfigs.debug
}
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
}
tasks.register('copyNativeLibs', Copy) {
delete('src/main/jniLibs')
from('../../libclash/android')
into('src/main/jniLibs')
}
tasks.withType(MergeSourceSetFolders).configureEach {
dependsOn copyNativeLibs
}
flutter {
source '../..'
}
dependencies {
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'com.google.code.gson:gson:2.10'
implementation("com.android.tools.smali:smali-dexlib2:3.0.7") {
exclude group: "com.google.guava", module: "guava"
}
}
afterEvaluate {
assembleDebug.dependsOn copyNativeLibs
assembleRelease.dependsOn copyNativeLibs
}
+110
View File
@@ -0,0 +1,110 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
id("com.android.application")
id("dev.flutter.flutter-gradle-plugin")
}
val localProperties = Properties().apply {
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use { load(it) }
}
}
val releaseStoreFile = file("keystore.jks")
val releaseStorePassword = localProperties.getProperty("storePassword")
val releaseKeyAlias = localProperties.getProperty("keyAlias")
val releaseKeyPassword = localProperties.getProperty("keyPassword")
val hasReleaseSigning = releaseStoreFile.exists() &&
releaseStorePassword != null &&
releaseKeyAlias != null &&
releaseKeyPassword != null
android {
namespace = "com.follow.clash"
compileSdk = libs.versions.compileSdk.get().toInt()
ndkVersion = libs.versions.ndkVersion.get()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "com.follow.clash"
minSdk = flutter.minSdkVersion
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = releaseStoreFile
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
packaging {
jniLibs {
useLegacyPackaging = true
}
}
buildTypes {
debug {
isMinifyEnabled = false
applicationIdSuffix = ".dev"
}
release {
isMinifyEnabled = true
isShrinkResources = true
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
} else {
signingConfig = signingConfigs.getByName("debug")
applicationIdSuffix = ".dev"
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
sourceSets {
// Unit tests live under android/tests/ instead of each module's src/test.
getByName("test").java.setSrcDirs(listOf("../tests/app"))
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
flutter {
source = "../.."
}
dependencies {
implementation(project(":service"))
implementation(project(":common"))
implementation(project(":core"))
implementation(libs.core.splashscreen)
implementation(libs.gson)
implementation(libs.smali.dexlib2) {
exclude(group = "com.google.guava", module = "guava")
}
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
+3 -1
View File
@@ -1,2 +1,4 @@
-keep class com.follow.clash.models.**{ *; }
-keep class com.follow.clash.models.** { *; }
-keep class com.follow.clash.service.models.** { *; }
+9 -10
View File
@@ -1,13 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<application android:label="FlClash Debug" tools:replace="android:label">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:icon="@mipmap/ic_launcher"
android:label="FlClash Debug"
tools:replace="android:label">
<service
android:name=".services.FlClashTileService"
android:label="FlClash Debug"
tools:replace="android:label">
</service>
android:name=".TileService"
android:label="FlClash Debug"
tools:replace="android:label" />
</application>
</manifest>
+43 -65
View File
@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
@@ -7,44 +8,54 @@
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.wifi"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
tools:ignore="SystemPermissionTypo" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<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="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:name=".FlClashApplication"
android:banner="@mipmap/ic_banner"
android:hardwareAccelerated="true"
android:label="FlClash">
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name">
<activity
android:name="com.follow.clash.MainActivity"
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<!-- install-config links go through app_links; Flutter would also push them as routes. -->
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
@@ -63,30 +74,30 @@
</intent-filter>
</activity>
<!-- <meta-data-->
<!-- android:name="io.flutter.embedding.android.EnableImpeller"-->
<!-- android:value="true" />-->
<activity
android:name=".TempActivity"
android:name=".QuickActionActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@style/TransparentTheme">
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="com.follow.clash.action.START" />
<action android:name="${applicationId}.action.START" />
</intent-filter>
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="com.follow.clash.action.STOP" />
<action android:name="${applicationId}.action.STOP" />
</intent-filter>
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="${applicationId}.action.TOGGLE" />
</intent-filter>
</activity>
<service
android:name=".services.FlClashTileService"
android:name=".TileService"
android:exported="true"
android:foregroundServiceType="specialUse"
android:icon="@drawable/ic_stat_name"
android:label="FlClash"
android:icon="@drawable/ic"
android:label="@string/app_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
@@ -96,52 +107,19 @@
android:value="true" />
</service>
<provider
android:name=".FilesProvider"
android:authorities="${applicationId}.files"
<receiver
android:name=".ServiceBroadcastReceiver"
android:enabled="true"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:process=":background">
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
<action android:name="${applicationId}.intent.action.VPN_START_REQUESTED" />
<action android:name="${applicationId}.intent.action.VPN_REVOKED" />
</intent-filter>
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".services.FlClashVpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="vpn" />
</service>
<service
android:name=".services.FlClashService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="service" />
</service>
</receiver>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
</manifest>
@@ -1,10 +0,0 @@
package com.follow.clash
import com.follow.clash.models.VpnOptions
interface BaseServiceInterface {
fun start(options: VpnOptions): Int
fun stop()
fun startForeground(title: String, content: String)
}
@@ -0,0 +1,105 @@
package com.follow.clash
import android.app.Application
import android.content.Context.MODE_PRIVATE
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.core.graphics.drawable.toBitmap
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.google.gson.Gson
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
private const val ICON_TTL_DAYS = 1L
val Application.sharedState: SharedState
get() = try {
val preferences = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
val json = preferences.getString("flutter.sharedState", null)
Gson().fromJson(json, SharedState::class.java) ?: SharedState()
} catch (_: Exception) {
SharedState()
}
private var lastToast: Toast? = null
fun Application.showToast(text: String?) {
if (text.isNullOrEmpty()) return
Handler(Looper.getMainLooper()).post {
lastToast?.cancel()
lastToast = Toast.makeText(this, text, Toast.LENGTH_LONG).apply {
show()
}
}
}
suspend fun PackageManager.getPackageIconPath(packageName: String): String =
withContext(Dispatchers.IO) {
val cacheDir = GlobalState.application.cacheDir
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
return@withContext try {
val lastUpdateTime = getPackageInfo(packageName, 0).lastUpdateTime
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
if (iconFile.exists() && !isExpired(iconFile)) {
return@withContext iconFile.absolutePath
}
iconDir.listFiles { f -> f.name.startsWith("${packageName}_") }?.forEach(File::delete)
val icon = getApplicationIcon(packageName)
saveDrawableToFile(icon, iconFile)
iconFile.absolutePath
} catch (_: Exception) {
val defaultIconFile = File(iconDir, "default_icon.webp")
if (!defaultIconFile.exists()) {
saveDrawableToFile(defaultActivityIcon, defaultIconFile)
}
defaultIconFile.absolutePath
}
}
private suspend fun saveDrawableToFile(drawable: Drawable, file: File) {
val bitmap = withContext(Dispatchers.Default) {
drawable.toBitmap(width = 128, height = 128)
}
try {
@Suppress("DEPRECATION")
val format = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Bitmap.CompressFormat.WEBP_LOSSY
} else {
Bitmap.CompressFormat.WEBP
}
FileOutputStream(file).use { fos ->
bitmap.compress(format, 90, fos)
}
} finally {
if (!bitmap.isRecycled) bitmap.recycle()
}
}
private fun isExpired(file: File): Boolean {
val now = System.currentTimeMillis()
val age = now - file.lastModified()
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
}
inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
return plugins.get(T::class.java) as T?
}
fun MethodChannel.invokeMethodOnMainThread(method: String, arguments: Any? = null) {
Handler(Looper.getMainLooper()).post {
invokeMethod(method, arguments)
}
}
@@ -1,112 +0,0 @@
package com.follow.clash
import android.database.Cursor
import android.database.MatrixCursor
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsProvider
import java.io.File
import java.io.FileNotFoundException
class FilesProvider : DocumentsProvider() {
companion object {
private const val DEFAULT_ROOT_ID = "0"
private val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
)
private val DEFAULT_ROOT_COLUMNS = arrayOf(
Root.COLUMN_ROOT_ID,
Root.COLUMN_FLAGS,
Root.COLUMN_ICON,
Root.COLUMN_TITLE,
Root.COLUMN_SUMMARY,
Root.COLUMN_DOCUMENT_ID
)
}
override fun onCreate(): Boolean {
return true
}
override fun queryRoots(projection: Array<String>?): Cursor {
return MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
newRow().apply {
add(Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY)
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
add(Root.COLUMN_TITLE, context!!.getString(R.string.fl_clash))
add(Root.COLUMN_SUMMARY, "Data")
add(Root.COLUMN_DOCUMENT_ID, "/")
}
}
}
override fun queryChildDocuments(
parentDocumentId: String,
projection: Array<String>?,
sortOrder: String?
): Cursor {
val result = MatrixCursor(resolveDocumentProjection(projection))
val parentFile = if (parentDocumentId == "/") {
context?.filesDir
} else {
File(parentDocumentId)
} ?: throw FileNotFoundException("Parent directory not found")
parentFile.listFiles()?.forEach { file ->
includeFile(result, file)
}
return result
}
override fun queryDocument(documentId: String, projection: Array<String>?): Cursor {
val result = MatrixCursor(resolveDocumentProjection(projection))
val file = File(documentId)
includeFile(result, file)
return result
}
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?
): ParcelFileDescriptor {
val file = File(documentId)
val accessMode = ParcelFileDescriptor.parseMode(mode)
return ParcelFileDescriptor.open(file, accessMode)
}
private fun includeFile(result: MatrixCursor, file: File) {
result.newRow().apply {
add(Document.COLUMN_DOCUMENT_ID, file.absolutePath)
add(Document.COLUMN_DISPLAY_NAME, file.name)
add(Document.COLUMN_SIZE, file.length())
add(
Document.COLUMN_FLAGS,
Document.FLAG_SUPPORTS_WRITE or Document.FLAG_SUPPORTS_DELETE
)
add(Document.COLUMN_MIME_TYPE, getDocumentType(file))
}
}
private fun getDocumentType(file: File): String {
return if (file.isDirectory) {
Document.MIME_TYPE_DIR
} else {
"application/octet-stream"
}
}
private fun resolveDocumentProjection(projection: Array<String>?): Array<String> {
return projection ?: DEFAULT_DOCUMENT_COLUMNS
}
}
@@ -0,0 +1,12 @@
package com.follow.clash
import android.app.Application
import android.content.Context
import com.follow.clash.common.GlobalState
class FlClashApplication : Application() {
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
GlobalState.init(this)
}
}
@@ -1,70 +0,0 @@
package com.follow.clash
import android.content.Context
import androidx.lifecycle.MutableLiveData
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ServicePlugin
import com.follow.clash.plugins.VpnPlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
enum class RunState {
START,
PENDING,
STOP
}
object GlobalState {
private val lock = ReentrantLock()
val runLock = ReentrantLock()
val runState: MutableLiveData<RunState> = MutableLiveData<RunState>(RunState.STOP)
var flutterEngine: FlutterEngine? = null
private var serviceEngine: FlutterEngine? = null
fun getCurrentAppPlugin(): AppPlugin? {
val currentEngine = if (flutterEngine != null) flutterEngine else serviceEngine
return currentEngine?.plugins?.get(AppPlugin::class.java) as AppPlugin?
}
fun getCurrentTilePlugin(): TilePlugin? {
val currentEngine = if (flutterEngine != null) flutterEngine else serviceEngine
return currentEngine?.plugins?.get(TilePlugin::class.java) as TilePlugin?
}
fun getCurrentVPNPlugin(): VpnPlugin? {
return serviceEngine?.plugins?.get(VpnPlugin::class.java) as VpnPlugin?
}
fun destroyServiceEngine() {
serviceEngine?.destroy()
serviceEngine = null
}
fun initServiceEngine(context: Context) {
if (serviceEngine != null) return
lock.withLock {
destroyServiceEngine()
serviceEngine = FlutterEngine(context)
serviceEngine?.plugins?.add(VpnPlugin())
serviceEngine?.plugins?.add(AppPlugin())
serviceEngine?.plugins?.add(TilePlugin())
serviceEngine?.plugins?.add(ServicePlugin())
val vpnService = DartExecutor.DartEntrypoint(
FlutterInjector.instance().flutterLoader().findAppBundlePath(),
"vpnService"
)
serviceEngine?.dartExecutor?.executeDartEntrypoint(
vpnService,
)
}
}
}
@@ -1,9 +1,8 @@
package com.follow.clash
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.BatteryPlugin
import com.follow.clash.plugins.ServicePlugin
import com.follow.clash.plugins.VpnPlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -12,14 +11,14 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(AppPlugin())
flutterEngine.plugins.add(VpnPlugin())
flutterEngine.plugins.add(BatteryPlugin())
flutterEngine.plugins.add(ServicePlugin())
flutterEngine.plugins.add(TilePlugin())
GlobalState.flutterEngine = flutterEngine
ServiceState.attachFlutterEngine(flutterEngine)
}
override fun onDestroy() {
GlobalState.flutterEngine = null
flutterEngine?.let(ServiceState::detachFlutterEngine)
super.onDestroy()
}
}
}
@@ -0,0 +1,37 @@
package com.follow.clash
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.core.content.pm.ShortcutManagerCompat
import com.follow.clash.common.GlobalState
import com.follow.clash.common.QuickAction
import com.follow.clash.common.action
import com.follow.clash.common.shortcutId
import kotlinx.coroutines.launch
class QuickActionActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handle(intent)
finish()
}
// A caller that repeats an action before this instance is gone reaches it here, not onCreate.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handle(intent)
}
private fun handle(intent: Intent) {
val action = QuickAction.entries.firstOrNull { it.action == intent.action } ?: return
ShortcutManagerCompat.reportShortcutUsed(this, action.shortcutId)
GlobalState.launch {
when (action) {
QuickAction.START -> ServiceState.handleStartAction()
QuickAction.STOP -> ServiceState.handleStopAction()
QuickAction.TOGGLE -> ServiceState.handleToggleAction()
}
}
}
}
@@ -0,0 +1,47 @@
package com.follow.clash
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.BroadcastLease
import com.follow.clash.common.GlobalState
import com.follow.clash.common.action
import kotlinx.coroutines.launch
class ServiceBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val action = intent?.action ?: return
val pendingResult = goAsync()
val lease = BroadcastLease { pendingResult.finish() }
val timeout = Runnable {
lease.release { GlobalState.log("Broadcast handling timed out: $action") }
}
mainHandler.postDelayed(timeout, BROADCAST_TIMEOUT_MILLIS)
GlobalState.launch {
try {
handleAction(action)
} catch (error: Exception) {
GlobalState.log("Unable to handle service broadcast $action: $error")
} finally {
mainHandler.removeCallbacks(timeout)
lease.release()
}
}
}
private suspend fun handleAction(action: String) {
when (action) {
BroadcastAction.VPN_START_REQUESTED.action -> ServiceState.handleStartAction()
BroadcastAction.VPN_REVOKED.action -> ServiceState.handleVpnRevokeAction()
}
}
companion object {
private const val BROADCAST_TIMEOUT_MILLIS = 9_000L
private val mainHandler = Handler(Looper.getMainLooper())
}
}
@@ -0,0 +1,249 @@
package com.follow.clash
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import com.follow.clash.common.GlobalState
import com.follow.clash.common.intent
import com.follow.clash.core.Core
import com.follow.clash.service.ManagedService
import com.follow.clash.service.ProxyService
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.VpnService
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.coroutines.resume
object ServiceController {
private val lock = Mutex()
private var binding: ManagedServiceBinding? = null
@Volatile
internal var runtime: ServiceRuntime? = null
private set
private fun clearBinding() {
binding?.unbind()
binding = null
}
// The Core listens exactly while a run exists, so every path that ends one closes it.
private fun release() {
clearBinding()
runtime = null
Core.stopListener()
}
fun invokeMethod(data: String, callback: (ByteArray) -> Unit): Result<Unit> = runCatching {
Core.invokeMethod(data) { result ->
callback(result ?: ByteArray(0))
}
}
suspend fun quickSetup(
initParams: String,
setupParams: String,
): Result<String> = runCatching {
suspendCancellableCoroutine { continuation ->
Core.quickSetup(initParams, setupParams) { result ->
continuation.resume(result.orEmpty())
}
}
}
fun setEventListener(callback: ((ByteArray) -> Unit)?): Result<Unit> = runCatching {
Core.updateEventListener(callback)
}
suspend fun start(options: VpnOptions): Boolean = lock.withLock {
ServiceConfig.updateVpnOptions(options)
val nextIntent = if (options.enable) {
VpnService::class.intent
} else {
ProxyService::class.intent
}
if (binding?.component != nextIntent.component) {
if (binding != null) {
tearDownServices()
}
lateinit var nextBinding: ManagedServiceBinding
nextBinding = ManagedServiceBinding(nextIntent) { message ->
handleServiceDisconnected(nextBinding, message)
}
binding = nextBinding
nextBinding.bind().onFailure { error ->
GlobalState.log("Unable to bind background service: $error")
release()
return@withLock false
}
}
val currentBinding = binding ?: return@withLock false
val result = currentBinding.useService { service -> service.start() }
if (result.isFailure) {
GlobalState.log("Unable to start background service: ${result.exceptionOrNull()}")
currentBinding.stopIfConnected()
.onFailure { error ->
GlobalState.log("Unable to clean up failed background service start: $error")
}
release()
return@withLock false
}
Core.startListener()
runtime = ServiceRuntime(
vpn = options.enable,
startedAtMillis = runtime?.startedAtMillis ?: System.currentTimeMillis(),
)
true
}
suspend fun stop() = lock.withLock {
tearDownServices()
release()
}
private suspend fun tearDownServices() {
binding?.useService { service -> service.stop() }
?.onFailure { error ->
GlobalState.log("Unable to stop background service: $error")
}
clearBinding()
stopServices()
}
// A service the system started itself — always-on VPN, or the sticky restart
// after the process was killed — outlives every binding this process holds, so
// unbinding alone leaves the tunnel up while the app reports it stopped.
private fun stopServices() {
listOf(VpnService::class.intent, ProxyService::class.intent).forEach { intent ->
runCatching { GlobalState.application.stopService(intent) }
.onFailure { error ->
GlobalState.log("Unable to stop ${intent.component?.className}: $error")
}
}
}
private fun handleServiceDisconnected(
disconnectedBinding: ManagedServiceBinding,
message: String,
) {
val token = ServiceState.captureRequestToken()
GlobalState.launch {
val wasCurrent = lock.withLock {
if (binding !== disconnectedBinding) {
return@withLock false
}
GlobalState.log("Background service disconnected: $message")
release()
true
}
if (wasCurrent) {
ServiceState.handleServiceLost(token)
}
}
}
}
private class ManagedServiceBinding(
private val intent: Intent,
private val onDisconnected: (String) -> Unit,
) : ServiceConnection {
val component: ComponentName?
get() = intent.component
private val serviceState = MutableStateFlow<Result<ManagedService>?>(null)
@Volatile
private var isBound = false
suspend fun bind(): Result<Unit> = runCatching {
withContext(Dispatchers.Main.immediate) {
serviceState.value = null
isBound = GlobalState.application.bindService(
intent,
this@ManagedServiceBinding,
Context.BIND_AUTO_CREATE,
)
check(isBound) { "bindService() failed" }
}
}
suspend fun <R> useService(
connectionTimeoutMillis: Long = 5_000,
block: suspend (ManagedService) -> R,
): Result<R> = runCatching {
val service = withTimeout(connectionTimeoutMillis) {
serviceState.filterNotNull().first().getOrThrow()
}
withContext(Dispatchers.Default) {
block(service)
}
}
suspend fun stopIfConnected(): Result<Unit> = runCatching {
val service = serviceState.value?.getOrNull() ?: return@runCatching
withContext(Dispatchers.Default) {
service.stop()
}
}
fun unbind() {
serviceState.value = null
if (!isBound) return
isBound = false
Handler(Looper.getMainLooper()).post {
runCatching {
GlobalState.application.unbindService(this)
}.onFailure { error ->
GlobalState.log("Unable to unbind background service: $error")
}
}
}
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
runCatching {
when (binder) {
is VpnService.LocalBinder -> binder.service
is ProxyService.LocalBinder -> binder.service
null -> error("Binder is empty")
else -> error("Unsupported service binder: ${binder.javaClass.name}")
}
}.onSuccess { service ->
serviceState.value = Result.success(service)
}.onFailure { error ->
disconnect(error.message.orEmpty())
}
}
override fun onServiceDisconnected(name: ComponentName?) {
disconnect("Service disconnected")
}
override fun onBindingDied(name: ComponentName?) {
disconnect("Service binding died")
}
override fun onNullBinding(name: ComponentName?) {
disconnect("Service returned an empty binder")
}
private fun disconnect(message: String) {
serviceState.value = Result.failure(IllegalStateException(message))
onDisconnected(message)
}
}
@@ -0,0 +1,40 @@
package com.follow.clash
import com.follow.clash.models.SharedState
import io.flutter.embedding.engine.FlutterEngine
import kotlinx.coroutines.Deferred
object ServiceState {
private val machine = ServiceStateMachine(AndroidServiceStateHost)
val runState = machine.runState
val isRunRequested: Boolean
get() = machine.isRunRequested
fun attachFlutterEngine(engine: FlutterEngine) =
AndroidServiceStateHost.attachFlutterEngine(engine)
fun detachFlutterEngine(engine: FlutterEngine) =
AndroidServiceStateHost.detachFlutterEngine(engine)
suspend fun handleToggleAction() = machine.handleToggleAction()
suspend fun handleStartAction() = machine.handleStartAction()
suspend fun handleStopAction() = machine.handleStopAction()
suspend fun handleVpnRevokeAction() = machine.handleVpnRevokeAction()
suspend fun awaitRunTime(): Long = machine.awaitRunTime()
fun requestStart(): Deferred<Boolean> = machine.requestStart()
fun requestStop(): Deferred<Boolean> = machine.requestStop()
fun syncSharedState(state: SharedState) = machine.syncSharedState(state)
internal fun captureRequestToken(): RunRequest = machine.captureRequestToken()
internal suspend fun handleServiceLost(token: RunRequest) = machine.handleServiceLost(token)
}
@@ -0,0 +1,149 @@
package com.follow.clash
import android.Manifest
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import androidx.core.content.ContextCompat
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ServicePlugin
import com.follow.clash.plugins.TilePlugin
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import io.flutter.embedding.engine.FlutterEngine
import kotlinx.coroutines.CoroutineScope
internal data class ServiceRuntime(
val vpn: Boolean,
val startedAtMillis: Long,
)
/**
* The machine owns the arbitration and the transitions; this is only the part that cannot run on a
* plain JVM, so unit tests can drive the state machine with an in-memory implementation.
*/
internal interface ServiceStateHost {
val scope: CoroutineScope
val runtime: ServiceRuntime?
val homeDirPath: String
val sdkInt: Int
fun log(message: String)
fun showToast(message: String)
fun updateNotificationParams(params: NotificationParams)
fun loadSharedState(): SharedState
fun isVpnPermissionGranted(): Boolean
fun isLocalNetworkPermissionGranted(): Boolean
fun tile(): TileGateway?
fun app(): AppGateway?
fun notifyStopped()
suspend fun quickSetup(initParams: String, setupParams: String): Result<String>
suspend fun startService(options: VpnOptions): Boolean
suspend fun stopService()
}
internal fun interface TileGateway {
fun handleStart()
}
internal interface AppGateway {
fun requestNotificationPermission(callback: (Boolean) -> Unit)
fun requestLocalNetworkPermission(callback: (Boolean) -> Unit)
fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit)
fun cancelVpnPreparation(callback: (Boolean) -> Unit)
}
internal object AndroidServiceStateHost : ServiceStateHost {
@Volatile
private var flutterEngine: FlutterEngine? = null
override val scope: CoroutineScope
get() = GlobalState
override val runtime: ServiceRuntime?
get() = ServiceController.runtime
override val homeDirPath: String
get() = GlobalState.application.filesDir.path
override val sdkInt: Int
get() = android.os.Build.VERSION.SDK_INT
fun attachFlutterEngine(engine: FlutterEngine) {
flutterEngine = engine
}
fun detachFlutterEngine(engine: FlutterEngine) {
if (flutterEngine === engine) {
flutterEngine = null
}
}
override fun log(message: String) = GlobalState.log(message)
override fun showToast(message: String) = GlobalState.application.showToast(message)
override fun updateNotificationParams(params: NotificationParams) =
ServiceConfig.updateNotificationParams(params)
override fun loadSharedState(): SharedState = GlobalState.application.sharedState
override fun isVpnPermissionGranted(): Boolean =
VpnService.prepare(GlobalState.application) == null
override fun isLocalNetworkPermissionGranted(): Boolean =
sdkInt < Build.VERSION_CODES.CINNAMON_BUN ||
ContextCompat.checkSelfPermission(
GlobalState.application,
Manifest.permission.ACCESS_LOCAL_NETWORK,
) == PackageManager.PERMISSION_GRANTED
override fun tile(): TileGateway? = flutterEngine?.plugin<TilePlugin>()?.let { plugin ->
TileGateway(plugin::handleStart)
}
override fun app(): AppGateway? = flutterEngine?.plugin<AppPlugin>()?.let { plugin ->
object : AppGateway {
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
plugin.requestNotificationPermission(callback)
override fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) =
plugin.requestLocalNetworkPermission(callback)
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) =
plugin.prepareVpn(enable, callback)
override fun cancelVpnPreparation(callback: (Boolean) -> Unit) =
plugin.cancelVpnPreparation(callback)
}
}
override fun notifyStopped() {
flutterEngine?.plugin<ServicePlugin>()?.notifyStopped()
}
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> =
ServiceController.quickSetup(initParams, setupParams)
override suspend fun startService(options: VpnOptions): Boolean =
ServiceController.start(options)
override suspend fun stopService() = ServiceController.stop()
}
@@ -0,0 +1,382 @@
package com.follow.clash
import com.follow.clash.common.RunIntentArbiter
import com.follow.clash.models.SharedState
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import com.google.gson.Gson
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
enum class RunState {
STARTED,
STARTING,
STOPPING,
STOPPED,
}
internal typealias RunRequest = RunIntentArbiter.Token
internal const val MISSING_CONFIG_MESSAGE = "No configuration found."
internal const val INVALID_CONFIG_MESSAGE = "Invalid configuration."
internal const val VPN_PERMISSION_MESSAGE = "VPN permission required."
internal const val START_FAILED_MESSAGE = "Failed to start service."
private enum class StartOutcome {
STARTED,
FAILED,
SUPERSEDED,
}
/**
* Callers request a transition; the newest request always wins. No step assigns [runState]:
* [publish] derives it from the latest intent, the transition in flight and what the host really
* runs, so no path can leave it claiming a service that is not there.
*/
internal class ServiceStateMachine(private val host: ServiceStateHost) {
private val transitionLock = Mutex()
private val startPreparationLock = Mutex()
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
private val arbiter = RunIntentArbiter()
@Volatile
private var sharedState = SharedState()
@Volatile
private var pendingVpnPreparation: (() -> Unit)? = null
@Volatile
private var transition: RunState? = null
val runState = mutableRunState.asStateFlow()
val isRunRequested: Boolean
get() = arbiter.isRunningRequested
suspend fun handleToggleAction() {
if (isRunRequested) {
handleStopAction()
} else {
handleStartAction()
}
}
suspend fun awaitRunTime(): Long = transitionLock.withLock {
host.runtime?.startedAtMillis?.takeIf { isRunRequested } ?: 0L
}
fun captureRequestToken(): RunRequest = arbiter.current()
/**
* Settles the state after the bound service was lost. [token] is the request that was current
* when the loss was observed, so a start that raced ahead of this callback keeps its intent.
*/
suspend fun handleServiceLost(token: RunRequest) {
val dropped = transitionLock.withLock {
if (host.runtime != null) {
return@withLock false
}
arbiter.resetToStopped(token).also { publish() }
}
if (dropped) {
host.notifyStopped()
}
}
suspend fun handleStartAction() {
if (isRunRequested) {
return
}
val tile = host.tile()
if (tile != null) {
tile.handleStart()
return
}
startFromPreferences()
}
suspend fun handleStopAction() {
if (!isRunRequested) {
return
}
host.showToast(sharedState.stopTip)
requestStop().await()
host.notifyStopped()
}
suspend fun handleVpnRevokeAction() {
if (host.runtime?.vpn != true) {
return
}
handleStopAction()
}
fun requestStart(): Deferred<Boolean> {
if (isStartedAs(sharedState.vpnOptions)) {
return CompletableDeferred(true)
}
val outcome = launchStart(createRequest(running = true))
return host.scope.async { outcome.await() == StartOutcome.STARTED }
}
fun requestStop(): Deferred<Boolean> {
val request = createRequest(running = false)
return host.scope.async { stop(request) }
}
fun syncSharedState(state: SharedState) {
sharedState = state
applySharedState()
}
// No new request for a settled run: one refused at a prompt would take the service down.
private fun isStartedAs(options: VpnOptions?): Boolean =
runState.value == RunState.STARTED && host.runtime?.vpn == options?.enable
// The intent is claimed before the Core is set up, so a second action that lands meanwhile
// toggles this start off instead of racing a second setup against it.
private suspend fun startFromPreferences() {
val request = createRequest(running = true)
sharedState = host.loadSharedState()
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
host.showToast(MISSING_CONFIG_MESSAGE)
abandon(request)
return
}
if (!setupCore()) {
abandon(request)
} else if (launchStart(request).await() == StartOutcome.FAILED) {
host.showToast(START_FAILED_MESSAGE)
}
}
private fun launchStart(request: RunRequest): Deferred<StartOutcome> {
val result = CompletableDeferred<StartOutcome>()
val launch: (Boolean) -> Unit = { permitted ->
host.scope.launch {
result.complete(if (permitted) start(request) else abandon(request))
}
}
val app = host.app()
if (app != null) {
app.requestNotificationPermission { permitted ->
if (permitted) {
app.requestLocalNetworkPermission { launch(true) }
} else {
launch(false)
}
}
} else {
launch(true)
}
return result
}
private fun applySharedState() {
host.updateNotificationParams(notificationParams(sharedState))
}
private suspend fun setupCore(): Boolean {
applySharedState()
host.showToast(sharedState.startTip)
return host.quickSetup(
initParams(host.homeDirPath, host.sdkInt),
Gson().toJson(sharedState.setupParams),
).fold(
onSuccess = { message ->
if (message.isEmpty()) {
true
} else {
host.log("Unable to set up core: $message")
showConfigError(message)
false
}
},
onFailure = { error ->
host.log("Unable to set up core: $error")
showConfigError(error.message)
false
},
)
}
private fun showConfigError(message: String?) {
host.showToast(message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE)
}
private suspend fun start(request: RunRequest): StartOutcome {
val started = try {
startPreparationLock.withLock { runStart(request) }
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
host.log("Unable to process service start request: $error")
false
}
return if (started) StartOutcome.STARTED else abandon(request)
}
private suspend fun abandon(request: RunRequest): StartOutcome {
val dropped = arbiter.resetToStopped(request)
reconcileStopped()
if (!dropped) {
return StartOutcome.SUPERSEDED
}
host.notifyStopped()
return StartOutcome.FAILED
}
private suspend fun runStart(request: RunRequest): Boolean {
if (!isCurrent(request)) {
return false
}
val options = sharedState.vpnOptions ?: return false
if (!prepareVpn(options)) {
if (host.app() == null && isCurrent(request)) {
host.showToast(VPN_PERMISSION_MESSAGE)
}
return false
}
return transitionLock.withLock {
when {
!isCurrent(request) -> false
host.runtime?.vpn == options.enable -> true
else -> transitioning(RunState.STARTING) {
host.startService(withLocalNetworkFallback(options)) && isCurrent(request)
}
}
}
}
// system/mixed hand TCP to the kernel via the tun subnet, which Android 17 gates as local network.
private fun withLocalNetworkFallback(options: VpnOptions): VpnOptions {
if (!options.enable || options.stack !in KERNEL_TCP_STACKS ||
host.isLocalNetworkPermissionGranted()
) {
return options
}
host.showToast(sharedState.localNetworkTip)
return options.copy(stack = FALLBACK_STACK)
}
// Flutter's startListener and the native quickSetup both open the Core's listeners before any
// service exists to answer for them, and Flutter may be gone before it hears of the failure.
private suspend fun reconcileStopped() = transitionLock.withLock {
when {
isRunRequested -> publish()
host.runtime != null -> stopLocked()
else -> {
stopService()
publish()
}
}
}
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
if (!isCurrent(request)) {
return@withLock false
}
abandonVpnPreparation()
if (host.runtime != null) {
stopLocked()
}
isCurrent(request)
}
private suspend fun stopLocked() = transitioning(RunState.STOPPING) { stopService() }
private suspend fun stopService() {
try {
host.stopService()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
host.log("Unable to stop service: $error")
}
}
private inline fun <T> transitioning(state: RunState, block: () -> T): T {
transition = state
publish()
try {
return block()
} finally {
transition = null
publish()
}
}
@Synchronized
private fun publish() {
mutableRunState.value = transition ?: when {
host.runtime == null -> RunState.STOPPED
isRunRequested -> RunState.STARTED
else -> RunState.STOPPING
}
}
private suspend fun prepareVpn(options: VpnOptions): Boolean {
val app = host.app()
?: return !options.enable || host.isVpnPermissionGranted()
return suspendCancellableCoroutine { continuation ->
val callback: (Boolean) -> Unit = { granted ->
pendingVpnPreparation = null
if (continuation.isActive) {
continuation.resume(granted)
}
}
pendingVpnPreparation = {
app.cancelVpnPreparation(callback)
callback(false)
}
continuation.invokeOnCancellation {
pendingVpnPreparation = null
app.cancelVpnPreparation(callback)
}
app.prepareVpn(options.enable, callback)
}
}
private fun abandonVpnPreparation() {
val abandon = pendingVpnPreparation ?: return
pendingVpnPreparation = null
abandon()
}
private fun createRequest(running: Boolean): RunRequest =
arbiter.request(running).also { publish() }
private fun isCurrent(request: RunRequest): Boolean = arbiter.isCurrent(request)
internal companion object {
val KERNEL_TCP_STACKS = setOf("system", "mixed")
const val FALLBACK_STACK = "gvisor"
/**
* The Core init payload. The key spelling is a cross-language contract with the Go wrapper,
* not an implementation detail.
*/
fun initParams(homeDirPath: String, sdkInt: Int): String = Gson().toJson(
mapOf(
"home-dir" to homeDirPath,
"version" to sdkInt,
),
)
fun notificationParams(state: SharedState): NotificationParams = NotificationParams(
title = state.currentProfileName,
stopText = state.stopText,
onlyStatisticsProxy = state.onlyStatisticsProxy,
showStopAction = state.showStopAction,
)
}
}
@@ -1,20 +0,0 @@
package com.follow.clash
import android.app.Activity
import android.os.Bundle
class TempActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.action) {
"com.follow.clash.action.START" -> {
GlobalState.getCurrentTilePlugin()?.handleStart()
}
"com.follow.clash.action.STOP" -> {
GlobalState.getCurrentTilePlugin()?.handleStop()
}
}
finishAndRemoveTask()
}
}
@@ -0,0 +1,63 @@
package com.follow.clash
import android.annotation.SuppressLint
import android.os.Build
import android.service.quicksettings.Tile
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.toPendingIntent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class TileService : android.service.quicksettings.TileService() {
private var scope: CoroutineScope? = null
override fun onStartListening() {
super.onStartListening()
scope?.cancel()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { scope ->
scope.launch {
ServiceState.runState.collect(::updateTile)
}
}
}
// Always route through QuickActionActivity: startActivityAndCollapse is
// what closes the shade, so toggling the service in place would leave
// the panel open after a tap.
override fun onClick() {
super.onClick()
openQuickAction()
}
override fun onStopListening() {
scope?.cancel()
scope = null
super.onStopListening()
}
private fun updateTile(runState: RunState) {
qsTile?.apply {
state = when (runState) {
RunState.STARTED -> Tile.STATE_ACTIVE
RunState.STARTING, RunState.STOPPING -> Tile.STATE_UNAVAILABLE
RunState.STOPPED -> Tile.STATE_INACTIVE
}
updateTile()
}
}
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun openQuickAction() {
val intent = QuickAction.TOGGLE.quickIntent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(intent.toPendingIntent)
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}
}
@@ -1,89 +0,0 @@
package com.follow.clash.extensions
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.net.ConnectivityManager
import android.net.Network
import android.system.OsConstants.IPPROTO_TCP
import android.system.OsConstants.IPPROTO_UDP
import android.util.Base64
import androidx.core.graphics.drawable.toBitmap
import com.follow.clash.models.CIDR
import com.follow.clash.models.Metadata
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
suspend fun Drawable.getBase64(): String {
val drawable = this
return withContext(Dispatchers.IO) {
val bitmap = drawable.toBitmap()
val byteArrayOutputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
Base64.encodeToString(byteArrayOutputStream.toByteArray(), Base64.NO_WRAP)
}
}
fun Metadata.getProtocol(): Int? {
if (network.startsWith("tcp")) return IPPROTO_TCP
if (network.startsWith("udp")) return IPPROTO_UDP
return null
}
fun String.toCIDR(): CIDR {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
val ipAddress = parts[0]
val prefixLength = parts[1].toIntOrNull()
?: throw IllegalArgumentException("Invalid prefix length")
val address = InetAddress.getByName(ipAddress)
val maxPrefix = if (address.address.size == 4) 32 else 128
if (prefixLength < 0 || prefixLength > maxPrefix) {
throw IllegalArgumentException("Invalid prefix length for IP version")
}
return CIDR(address, prefixLength)
}
fun ConnectivityManager.resolveDns(network: Network?): List<String> {
val properties = getLinkProperties(network) ?: return listOf()
return properties.dnsServers.map { it.asSocketAddressText(53) }
}
fun InetAddress.asSocketAddressText(port: Int): String {
return when (this) {
is Inet6Address ->
"[${numericToTextFormat(this.address)}]:$port"
is Inet4Address ->
"${this.hostAddress}:$port"
else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}")
}
}
private fun numericToTextFormat(src: ByteArray): String {
val sb = StringBuilder(39)
for (i in 0 until 8) {
sb.append(
Integer.toHexString(
src[i shl 1].toInt() shl 8 and 0xff00
or (src[(i shl 1) + 1].toInt() and 0xff)
)
)
if (i < 7) {
sb.append(":")
}
}
return sb.toString()
}
@@ -0,0 +1,9 @@
package com.follow.clash.models
data class InstalledPackage(
val packageName: String,
val label: String,
val system: Boolean,
val internet: Boolean,
val lastUpdateTime: Long,
)
@@ -1,8 +0,0 @@
package com.follow.clash.models
data class Package(
val packageName: String,
val label: String,
val isSystem: Boolean,
val firstInstallTime: Long,
)
@@ -1,15 +0,0 @@
package com.follow.clash.models
data class Process(
val id: Int,
val metadata: Metadata,
)
data class Metadata(
val network: String,
val sourceIP: String,
val sourcePort: Int,
val destinationIP: String,
val destinationPort: Int,
val host: String
)
@@ -1,28 +0,0 @@
package com.follow.clash.models
import java.net.InetAddress
enum class AccessControlMode {
acceptSelected,
rejectSelected,
}
data class AccessControl(
val mode: AccessControlMode,
val acceptList: List<String>,
val rejectList: List<String>,
)
data class CIDR(val address: InetAddress, val prefixLength: Int)
data class VpnOptions(
val enable: Boolean,
val port: Int,
val accessControl: AccessControl?,
val allowBypass: Boolean,
val systemProxy: Boolean,
val bypassDomain: List<String>,
val ipv4Address: String,
val ipv6Address: String,
val dnsServerAddress: String,
)
@@ -0,0 +1,26 @@
package com.follow.clash.models
import com.follow.clash.service.models.VpnOptions
import com.google.gson.annotations.SerializedName
data class SharedState(
val startTip: String = "Starting VPN...",
val stopTip: String = "Stopping VPN...",
val localNetworkTip: String =
"Local network permission denied: using the gvisor stack, LAN is unreachable.",
val currentProfileName: String = "FlClash",
val stopText: String = "Stop",
val onlyStatisticsProxy: Boolean = false,
val showStopAction: Boolean = true,
val vpnOptions: VpnOptions? = null,
val setupParams: SetupParams? = null,
)
data class SetupParams(
@SerializedName("test-url")
val testUrl: String,
@SerializedName("selected-map")
val selectedMap: Map<String, String>,
@SerializedName("skip-cert-verify")
val skipCertVerify: Boolean = false,
)
@@ -0,0 +1,84 @@
package com.follow.clash.packages
/**
* The prefixes in [CHINA_PACKAGE_REGEX] are matched *without* a trailing dot
* boundary, and that is deliberate: `com.qihoo` has to reach `com.qihoo360.*`
* and `com.ali` has to reach `com.aliyun.*` and `com.alimama.*`. Requiring a
* separator would turn those into misses.
*
* The price is that unrelated names starting with the same letters match too.
* [SKIPPED_PREFIXES] is where those come back out, and it *does* apply a dot
* boundary, because there the entries are whole package roots.
*/
internal object ChinaPackageMatcher {
fun isSkipped(packageName: String): Boolean = SKIPPED_PREFIXES.any {
packageName == it || packageName.startsWith("$it.")
}
fun matchesKnownPrefix(name: String): Boolean = name.matches(CHINA_PACKAGE_REGEX)
fun classNameOf(descriptor: String): String = descriptor
.removeSurrounding("L", ";")
.replace('/', '.')
.replace('$', '.')
private val SKIPPED_PREFIXES = listOf(
"com.google",
"com.android.chrome",
"com.android.vending",
"com.microsoft",
"com.apple",
"com.zhiliaoapp.musically",
// Caught by the loose "com.mx" prefix, which targets Maxthon's
// com.mx.browser. MX Player is unrelated.
"com.mxtech",
// Caught by the loose "com.stub" prefix, which targets the
// com.stub.StubApp packer. StubHub is unrelated.
"com.stubhub",
)
private val CHINA_PACKAGE_REGEX = listOf(
"com.tencent",
"com.alibaba",
"com.umeng",
"com.qihoo",
"com.ali",
"com.alipay",
"com.amap",
"com.sina",
"com.weibo",
"com.vivo",
"com.xiaomi",
"com.huawei",
"com.taobao",
"com.secneo",
"s.h.e.l.l",
"com.stub",
"com.kiwisec",
"com.secshell",
"com.wrapper",
"cn.securitystack",
"com.mogosec",
"com.secoen",
"com.netease",
"com.mx",
"com.qq.e",
"com.baidu",
"com.bytedance",
"com.bugly",
"com.miui",
"com.oppo",
"com.coloros",
"com.iqoo",
"com.meizu",
"com.gionee",
"cn.nubia",
"com.oplus",
"andes.oplus",
"com.unionpay",
"cn.wps",
).joinToString("|", prefix = "(", postfix = ").*") { prefix ->
Regex.escape(prefix)
}.toRegex()
}
@@ -0,0 +1,139 @@
package com.follow.clash.packages
import android.Manifest
import android.content.pm.ApplicationInfo
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.os.Build
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import com.follow.clash.models.InstalledPackage
import java.io.File
import java.util.zip.ZipFile
internal class PackageResolver(
private val packageManager: PackageManager,
private val appPackageName: String,
) {
private val cacheLock = Any()
@Volatile
private var cachedPackages: List<InstalledPackage>? = null
val installedPackages: List<InstalledPackage>
get() = cachedPackages ?: synchronized(cacheLock) {
cachedPackages ?: loadPackages().also { cachedPackages = it }
}
val isInstalledAppsPermissionSupported: Boolean by lazy {
runCatching { packageManager.getPermissionInfo(GET_INSTALLED_APPS, 0) }.isSuccess
}
fun hasInstalledAppsPermission(): Boolean = !isInstalledAppsPermissionSupported ||
packageManager.checkPermission(GET_INSTALLED_APPS, appPackageName) ==
PackageManager.PERMISSION_GRANTED
fun invalidate() {
synchronized(cacheLock) { cachedPackages = null }
}
fun getChinaPackageNames(): List<String> = installedPackages
.map { it.packageName }
.filter(::isChinaPackage)
private fun loadPackages(): List<InstalledPackage> {
val flags = PackageManager.GET_PERMISSIONS
val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getInstalledPackages(
PackageManager.PackageInfoFlags.of(flags.toLong()),
)
} else {
@Suppress("DEPRECATION")
packageManager.getInstalledPackages(flags)
}
return packages.asSequence()
.filter { info ->
info.packageName != appPackageName && info.packageName != ANDROID_PACKAGE_NAME
}
.map { info ->
InstalledPackage(
packageName = info.packageName,
label = info.applicationInfo?.loadLabel(packageManager)?.toString()
?: info.packageName,
system = info.applicationInfo?.let { applicationInfo ->
applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
} == true,
internet = info.requestedPermissions
?.contains(Manifest.permission.INTERNET) == true,
lastUpdateTime = info.lastUpdateTime,
)
}.toList()
}
private fun isChinaPackage(packageName: String): Boolean {
if (ChinaPackageMatcher.isSkipped(packageName)) {
return false
}
if (ChinaPackageMatcher.matchesKnownPrefix(packageName)) {
return true
}
return runCatching {
val packageInfo = getPackageInfo(packageName)
packageInfo.componentNames().any(ChinaPackageMatcher::matchesKnownPrefix) ||
packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true
}.getOrDefault(false)
}
private fun getPackageInfo(packageName: String) = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
) {
packageManager.getPackageInfo(
packageName,
PackageManager.PackageInfoFlags.of(PACKAGE_INFO_FLAGS.toLong()),
)
} else {
@Suppress("DEPRECATION")
packageManager.getPackageInfo(packageName, PACKAGE_INFO_FLAGS)
}
private fun android.content.pm.PackageInfo.componentNames(): Sequence<String> = sequence {
yieldAll(services.orEmpty().asSequence().map(ComponentInfo::name))
yieldAll(activities.orEmpty().asSequence().map(ComponentInfo::name))
yieldAll(receivers.orEmpty().asSequence().map(ComponentInfo::name))
yieldAll(providers.orEmpty().asSequence().map(ComponentInfo::name))
}
private fun scanArchive(sourcePath: String): Boolean = ZipFile(File(sourcePath)).use { archive ->
if (archive.entries().asSequence().any { it.name.startsWith("firebase-") }) {
return false
}
archive.entries().asSequence()
.filter { entry ->
entry.name.startsWith("classes") && entry.name.endsWith(".dex")
}.any { entry ->
if (entry.size > MAX_DEX_SIZE_BYTES) {
return@any true
}
val dexFile = archive.getInputStream(entry).buffered().use { input ->
DexBackedDexFile.fromInputStream(null, input)
}
dexFile.classes.any { clazz ->
ChinaPackageMatcher.matchesKnownPrefix(
ChinaPackageMatcher.classNameOf(clazz.type),
)
}
}
}
companion object {
const val GET_INSTALLED_APPS = "com.android.permission.GET_INSTALLED_APPS"
private const val ANDROID_PACKAGE_NAME = "android"
private const val MAX_DEX_SIZE_BYTES = 15_000_000L
private val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
PackageManager.GET_SERVICES or
PackageManager.GET_RECEIVERS or
PackageManager.GET_PROVIDERS
}
}
@@ -3,136 +3,142 @@ package com.follow.clash.plugins
import android.Manifest
import android.app.Activity
import android.app.ActivityManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.ComponentInfo
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.widget.Toast
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.getSystemService
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import androidx.core.content.FileProvider
import com.follow.clash.GlobalState
import com.follow.clash.extensions.getBase64
import com.follow.clash.models.Package
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.follow.clash.R
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
import com.follow.clash.common.PendingCallback
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.registerReceiverCompat
import com.follow.clash.common.shortcutId
import com.follow.clash.getPackageIconPath
import com.follow.clash.packages.PackageResolver
import com.follow.clash.showToast
import com.google.gson.Gson
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.zip.ZipFile
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
private var activity: Activity? = null
private var toast: Toast? = null
private var activityBinding: ActivityPluginBinding? = null
private lateinit var context: Context
private val activityResultListener =
PluginRegistry.ActivityResultListener(::onActivityResult)
private val permissionsResultListener =
PluginRegistry.RequestPermissionsResultListener(::onRequestPermissionsResultListener)
private lateinit var channel: MethodChannel
private lateinit var scope: CoroutineScope
private var vpnCallBack: (() -> Unit)? = null
private val vpnPrepareCallback = PendingCallback<Boolean>()
private val iconMap = mutableMapOf<String, String?>()
private val requestNotificationCallback = PendingCallback<Boolean>()
private val packages = mutableListOf<Package>()
private val requestInstalledAppsCallback = PendingCallback<Boolean>()
private val skipPrefixList = listOf(
"com.google",
"com.android.chrome",
"com.android.vending",
"com.microsoft",
"com.apple",
"com.zhiliaoapp.musically", // Banned by China
)
private val requestLocalNetworkCallback = PendingCallback<Boolean>()
private val chinaAppPrefixList = listOf(
"com.tencent",
"com.alibaba",
"com.umeng",
"com.qihoo",
"com.ali",
"com.alipay",
"com.amap",
"com.sina",
"com.weibo",
"com.vivo",
"com.xiaomi",
"com.huawei",
"com.taobao",
"com.secneo",
"s.h.e.l.l",
"com.stub",
"com.kiwisec",
"com.secshell",
"com.wrapper",
"cn.securitystack",
"com.mogosec",
"com.secoen",
"com.netease",
"com.mx",
"com.qq.e",
"com.baidu",
"com.bytedance",
"com.bugly",
"com.miui",
"com.oppo",
"com.coloros",
"com.iqoo",
"com.meizu",
"com.gionee",
"cn.nubia",
"com.oplus",
"andes.oplus",
"com.unionpay",
"cn.wps"
)
private var isRequestingNotificationPermission = false
private val chinaAppRegex by lazy {
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
private var isRequestingLocalNetworkPermission = false
private var skipLocalNetworkPermissionRequest = false
private val gson = Gson()
private val packageResolver by lazy {
PackageResolver(
GlobalState.application.packageManager,
GlobalState.application.packageName,
)
}
val VPN_PERMISSION_REQUEST_CODE = 1001
private var packageChangeContext: Context? = null
val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
private var isBlockNotification: Boolean = false
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(Dispatchers.Default)
context = flutterPluginBinding.applicationContext;
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "app")
channel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
scope.cancel()
}
private fun tip(message: String?) {
if (GlobalState.flutterEngine == null) {
if (toast != null) {
toast!!.cancel()
private val packageChangeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent == null || intent.data?.schemeSpecificPart == GlobalState.application.packageName) {
return
}
toast = Toast.makeText(context, message, Toast.LENGTH_SHORT)
toast!!.show()
val addedByUpdate = intent.action == Intent.ACTION_PACKAGE_ADDED &&
intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
if (addedByUpdate) {
return
}
packageResolver.invalidate()
channel.invokeMethod("packagesChanged", null)
}
}
private var skipNotificationPermissionRequest = false
@Suppress("DEPRECATION")
private val isTelevision by lazy {
val packageManager = GlobalState.application.packageManager
packageManager.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}
private var floatingIme = false
// A TV keyboard floats over the app and reports a zero-height IME inset.
private val imeInsetsListener = OnApplyWindowInsetsListener { view, insets ->
val ime = WindowInsetsCompat.Type.ime()
reportFloatingIme(insets.isVisible(ime) && insets.getInsets(ime).bottom == 0)
ViewCompat.onApplyWindowInsets(view, insets)
}
private val mainHandler = Handler(Looper.getMainLooper())
/**
* The permission and consent hops below touch the Activity — starting an
* activity for result, raising a permission prompt — and read the state that
* tracks whether one is already up. Their callers are coroutines on
* [Dispatchers.Default], while the answers come back on the main thread
* through the ActivityAware listeners, so main is the one thread both ends
* can agree on. A plain main-looper post rather than the plugin scope: the
* scope is cancelled when the engine detaches, and a request dropped there
* would leave its caller waiting for a callback that can no longer run.
*/
private fun onMainThread(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
mainHandler.post(block)
}
}
@@ -140,300 +146,397 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
when (call.method) {
"moveTaskToBack" -> {
activity?.moveTaskToBack(true)
result.success(true);
result.success(true)
}
"updateExcludeFromRecents" -> {
val value = call.argument<Boolean>("value")
updateExcludeFromRecents(value)
result.success(true);
result.success(true)
}
"getPackages" -> {
scope.launch {
result.success(getPackagesToJson())
"initShortcuts" -> {
val labels = shortcutLabels(call.arguments)
if (labels == null) {
result.error(
"INVALID_ARGUMENT",
"Shortcut labels must map every quick action to a string",
null,
)
} else {
initShortcuts(labels)
result.success(true)
}
}
"getChinaPackageNames" -> {
scope.launch {
result.success(getChinaPackageNames())
}
"getPackages" -> reply(result) {
gson.toJson(packageResolver.installedPackages)
}
"getChinaPackageNames" -> reply(result) {
gson.toJson(packageResolver.getChinaPackageNames())
}
"isInstalledAppsPermissionGranted" -> reply(result) {
packageResolver.hasInstalledAppsPermission()
}
"requestInstalledAppsPermission" -> {
requestInstalledAppsPermission { granted -> result.success(granted) }
}
"getPackageIcon" -> {
scope.launch {
val packageName = call.argument<String>("packageName")
if (packageName == null) {
result.success(null)
return@launch
}
val packageIcon = getPackageIcon(packageName)
packageIcon.let {
if (it != null) {
result.success(it)
return@launch
}
if (iconMap["default"] == null) {
iconMap["default"] =
context.packageManager?.defaultActivityIcon?.getBase64()
}
result.success(iconMap["default"])
return@launch
}
}
handleGetPackageIcon(call, result)
}
"tip" -> {
val message = call.argument<String>("message")
tip(message)
GlobalState.application.showToast(message)
result.success(true)
}
"openFile" -> {
val path = call.argument<String>("path")!!
openFile(path)
result.success(true)
"openAppSettings" -> {
result.success(openAppSettings())
}
"getLastExitInfo" -> reply(result) {
GlobalState.lastExitInfo()
}
else -> {
result.notImplemented();
result.notImplemented()
}
}
}
private fun openFile(path: String) {
val file = File(path)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileProvider",
file
)
private fun handleGetPackageIcon(call: MethodCall, result: Result) = reply(result) {
val packageName = call.argument<String>("packageName") ?: ""
GlobalState.application.packageManager.getPackageIconPath(packageName)
}
val intent = Intent(Intent.ACTION_VIEW).setDataAndType(
uri,
"text/plain"
)
val flags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
val resInfoList = context.packageManager.queryIntentActivities(
intent, PackageManager.MATCH_DEFAULT_ONLY
)
for (resolveInfo in resInfoList) {
val packageName = resolveInfo.activityInfo.packageName
context.grantUriPermission(
packageName,
uri,
flags
)
}
try {
activity?.startActivity(intent)
} catch (e: Exception) {
println(e)
// A throw inside the plugin scope has no handler and ends the process;
// the Dart caller gets a PlatformException to handle instead.
private fun reply(result: Result, block: suspend () -> Any?) {
scope.launch(Dispatchers.IO) {
try {
result.success(block())
} catch (error: Exception) {
GlobalState.log("Platform call failed: $error")
result.error("PLATFORM_ERROR", error.toString(), null)
}
}
}
private fun shortcutLabels(arguments: Any?): Map<QuickAction, String>? {
val map = arguments as? Map<*, *> ?: return null
return QuickAction.entries.associateWith { action ->
map[action.shortcutId] as? String ?: return null
}
}
private fun initShortcuts(labels: Map<QuickAction, String>) {
val shortcuts = SHORTCUT_ORDER.mapIndexed { rank, action ->
with(ShortcutInfoCompat.Builder(GlobalState.application, action.shortcutId)) {
setShortLabel(labels.getValue(action))
setIcon(IconCompat.createWithResource(GlobalState.application, action.shortcutIcon))
setIntent(action.quickIntent)
setRank(rank)
build()
}
}
ShortcutManagerCompat.setDynamicShortcuts(GlobalState.application, shortcuts)
}
// Plain circles, not adaptive icons: an adaptive shortcut icon takes the
// launcher's own mask, which is a square on many devices.
private val QuickAction.shortcutIcon: Int
get() = when (this) {
QuickAction.START -> R.drawable.ic_shortcut_start
QuickAction.STOP -> R.drawable.ic_shortcut_stop
QuickAction.TOGGLE -> R.drawable.ic_shortcut_toggle
}
private fun openAppSettings(): Boolean {
val activity = activity ?: return false
return try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:${GlobalState.application.packageName}".toUri()
}
activity.startActivity(intent)
true
} catch (_: Exception) {
false
}
}
@Suppress("DEPRECATION")
private fun updateExcludeFromRecents(value: Boolean?) {
val am = getSystemService(context, ActivityManager::class.java)
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
}
}
when (value) {
true -> task?.setExcludeFromRecents(value)
false -> task?.setExcludeFromRecents(value)
null -> task?.setExcludeFromRecents(false)
}
task?.setExcludeFromRecents(value ?: false)
}
private suspend fun getPackageIcon(packageName: String): String? {
val packageManager = context.packageManager
if (iconMap[packageName] == null) {
iconMap[packageName] = try {
packageManager?.getApplicationIcon(packageName)?.getBase64()
} catch (_: Exception) {
null
}
}
return iconMap[packageName]
}
private fun getPackages(): List<Package> {
val packageManager = context.packageManager
if (packages.isNotEmpty()) return packages;
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA)?.filter {
it.packageName != context.packageName
|| it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|| it.packageName == "android"
}?.map {
Package(
packageName = it.packageName,
label = it.applicationInfo.loadLabel(packageManager).toString(),
isSystem = (it.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM) == 1,
firstInstallTime = it.firstInstallTime
)
}?.let { packages.addAll(it) }
return packages;
}
private suspend fun getPackagesToJson(): String {
return withContext(Dispatchers.Default) {
Gson().toJson(getPackages())
}
}
private suspend fun getChinaPackageNames(): String {
return withContext(Dispatchers.Default) {
val packages: List<String> =
getPackages().map { it.packageName }.filter { isChinaPackage(it) }
Gson().toJson(packages)
}
}
fun requestVpnPermission(context: Context, callBack: () -> Unit) {
vpnCallBack = callBack
val intent = VpnService.prepare(context)
if (intent != null) {
activity?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
return;
}
vpnCallBack?.invoke()
}
fun requestNotificationsPermission(context: Context) {
fun requestNotificationPermission(callback: (Boolean) -> Unit) = onMainThread {
requestNotificationCallback.replace(callback, supersededValue = false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
GlobalState.application,
Manifest.permission.POST_NOTIFICATIONS,
)
if (permission != PackageManager.PERMISSION_GRANTED) {
if (isBlockNotification) return
if (activity == null) return
ActivityCompat.requestPermissions(
activity!!,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE
)
return
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
invokeRequestNotificationCallback(true)
return@onMainThread
}
if (isRequestingNotificationPermission) {
return@onMainThread
}
isRequestingNotificationPermission = true
activity?.let {
ActivityCompat.requestPermissions(
it,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE,
)
} ?: invokeRequestNotificationCallback(true)
return@onMainThread
}
invokeRequestNotificationCallback(true)
}
private fun isChinaPackage(packageName: String): Boolean {
val packageManager = context.packageManager ?: return false
skipPrefixList.forEach {
if (packageName == it || packageName.startsWith("$it.")) return false
private fun invokeRequestNotificationCallback(shouldStart: Boolean) {
isRequestingNotificationPermission = false
requestNotificationCallback.resolve(shouldStart)
}
fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) = onMainThread {
requestLocalNetworkCallback.replace(callback, supersededValue = false)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN || hasLocalNetworkPermission()) {
invokeRequestLocalNetworkCallback(true)
return@onMainThread
}
val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
} else {
@Suppress("DEPRECATION")
PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
if (skipLocalNetworkPermissionRequest) {
invokeRequestLocalNetworkCallback(false)
return@onMainThread
}
if (packageName.matches(chinaAppRegex)) {
return true
if (isRequestingLocalNetworkPermission) {
return@onMainThread
}
try {
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(
packageName,
PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong())
)
val activity = activity
if (activity == null) {
invokeRequestLocalNetworkCallback(false)
return@onMainThread
}
isRequestingLocalNetworkPermission = true
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.ACCESS_LOCAL_NETWORK),
LOCAL_NETWORK_PERMISSION_REQUEST_CODE,
)
}
private fun hasLocalNetworkPermission(): Boolean = ContextCompat.checkSelfPermission(
GlobalState.application,
Manifest.permission.ACCESS_LOCAL_NETWORK,
) == PackageManager.PERMISSION_GRANTED
private fun invokeRequestLocalNetworkCallback(granted: Boolean) {
isRequestingLocalNetworkPermission = false
requestLocalNetworkCallback.resolve(granted)
}
private fun requestInstalledAppsPermission(callback: (Boolean) -> Unit) = onMainThread {
requestInstalledAppsCallback.replace(callback, supersededValue = false)
val activity = activity
if (packageResolver.hasInstalledAppsPermission()) {
invokeRequestInstalledAppsCallback(true)
return@onMainThread
}
if (activity == null) {
invokeRequestInstalledAppsCallback(false)
return@onMainThread
}
ActivityCompat.requestPermissions(
activity,
arrayOf(PackageResolver.GET_INSTALLED_APPS),
INSTALLED_APPS_PERMISSION_REQUEST_CODE,
)
}
private fun invokeRequestInstalledAppsCallback(granted: Boolean) {
if (granted) {
packageResolver.invalidate()
}
requestInstalledAppsCallback.resolve(granted)
}
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) = onMainThread {
vpnPrepareCallback.replace(callback, supersededValue = false)
if (!needPrepare) {
invokeVpnPrepareCallback(true)
return@onMainThread
}
val intent = VpnService.prepare(GlobalState.application)
if (intent != null) {
val activity = activity
if (activity == null) {
invokeVpnPrepareCallback(false)
} else {
@Suppress("DEPRECATION") packageManager.getPackageInfo(
packageName, packageManagerFlags
)
@Suppress("DEPRECATION")
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
}
mutableListOf<ComponentInfo>().apply {
packageInfo.services?.let { addAll(it) }
packageInfo.activities?.let { addAll(it) }
packageInfo.receivers?.let { addAll(it) }
packageInfo.providers?.let { addAll(it) }
}.forEach {
if (it.name.matches(chinaAppRegex)) return true
}
ZipFile(File(packageInfo.applicationInfo.publicSourceDir)).use {
for (packageEntry in it.entries()) {
if (packageEntry.name.startsWith("firebase-")) return false
}
for (packageEntry in it.entries()) {
if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith(
".dex"
))
) {
continue
}
if (packageEntry.size > 15000000) {
return true
}
val input = it.getInputStream(packageEntry).buffered()
val dexFile = try {
DexBackedDexFile.fromInputStream(null, input)
} catch (e: Exception) {
return false
}
for (clazz in dexFile.classes) {
val clazzName =
clazz.type.substring(1, clazz.type.length - 1).replace("/", ".")
.replace("$", ".")
if (clazzName.matches(chinaAppRegex)) return true
}
}
}
} catch (_: Exception) {
return false
return@onMainThread
}
return false
invokeVpnPrepareCallback(true)
}
// Posted rather than run where the cancellation lands, so it stays ordered
// behind the prepareVpn that installed the callback it is cancelling.
fun cancelVpnPreparation(callback: (Boolean) -> Unit) = onMainThread {
vpnPrepareCallback.cancel(callback)
}
private fun invokeVpnPrepareCallback(granted: Boolean) {
vpnPrepareCallback.resolve(granted)
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
channel =
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
channel.setMethodCallHandler(this)
watchPackageChanges(flutterPluginBinding.applicationContext)
}
private fun watchPackageChanges(context: Context) {
val filter = IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
addDataScheme("package")
}
context.registerReceiverCompat(packageChangeReceiver, filter)
packageChangeContext = context
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
packageChangeContext?.unregisterReceiver(packageChangeReceiver)
packageChangeContext = null
channel.setMethodCallHandler(null)
scope.cancel()
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
invokeRequestLocalNetworkCallback(false)
invokeRequestInstalledAppsCallback(false)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity;
binding.addActivityResultListener(::onActivityResult)
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
attachToActivity(binding)
}
private fun attachToActivity(binding: ActivityPluginBinding) {
detachFromActivity()
activityBinding = binding
activity = binding.activity
binding.addActivityResultListener(activityResultListener)
binding.addRequestPermissionsResultListener(permissionsResultListener)
if (isTelevision) {
ViewCompat.setOnApplyWindowInsetsListener(
binding.activity.window.decorView,
imeInsetsListener,
)
}
}
private fun detachFromActivity() {
if (isTelevision) {
activity?.window?.decorView?.let {
ViewCompat.setOnApplyWindowInsetsListener(it, null)
}
reportFloatingIme(false)
}
activity = null
val binding = activityBinding ?: return
activityBinding = null
binding.removeActivityResultListener(activityResultListener)
binding.removeRequestPermissionsResultListener(permissionsResultListener)
}
private fun reportFloatingIme(floating: Boolean) {
if (floating == floatingIme) {
return
}
floatingIme = floating
channel.invokeMethod("floatingImeChanged", floating)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
detachFromActivity()
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity;
attachToActivity(binding)
}
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 {
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
if (resultCode == FlutterActivity.RESULT_OK) {
GlobalState.initServiceEngine(context)
vpnCallBack?.invoke()
}
if (requestCode != VPN_PERMISSION_REQUEST_CODE) {
return false
}
invokeVpnPrepareCallback(resultCode == Activity.RESULT_OK)
return true
}
private fun onRequestPermissionsResultListener(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
): Boolean {
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
isBlockNotification = true
grantResults: IntArray,
): Boolean = when (requestCode) {
NOTIFICATION_PERMISSION_REQUEST_CODE -> {
skipNotificationPermissionRequest = true
invokeRequestNotificationCallback(true)
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 {
val SHORTCUT_ORDER = listOf(QuickAction.START, QuickAction.STOP, QuickAction.TOGGLE)
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
const val INSTALLED_APPS_PERMISSION_REQUEST_CODE = 1003
const val LOCAL_NETWORK_PERMISSION_REQUEST_CODE = 1004
}
}
@@ -0,0 +1,241 @@
package com.follow.clash.plugins
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.database.ContentObserver
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.Settings
import androidx.core.net.toUri
import com.follow.clash.common.Components
import com.follow.clash.common.PendingCallback
import com.follow.clash.common.registerReceiverCompat
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
class BatteryPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware,
EventChannel.StreamHandler {
private lateinit var context: Context
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var activityBinding: ActivityPluginBinding? = null
private val pendingRequest = PendingCallback<Unit>()
private var events: EventChannel.EventSink? = null
private var lastPublished: Boolean? = null
private val activityResultListener =
PluginRegistry.ActivityResultListener { requestCode, _, _ ->
if (requestCode != REQUEST_CODE) {
return@ActivityResultListener false
}
resolve()
true
}
private val allowlistReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
publish()
}
}
private val saverObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
publish()
}
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/battery")
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(
binding.binaryMessenger,
"${Components.PACKAGE_NAME}/battery/optimization",
)
eventChannel.setStreamHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
onCancel(null)
resolve()
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"requestIgnoreOptimizations" -> requestIgnoreOptimizations { result.success(null) }
else -> result.notImplemented()
}
}
override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
val ignoring = isIgnoringOptimizations()
sink.success(ignoring)
context.registerReceiverCompat(
allowlistReceiver,
IntentFilter(ACTION_POWER_SAVE_WHITELIST_CHANGED),
)
context.contentResolver.registerContentObserver(
Settings.System.getUriFor(MILLET_NO_RESTRICT_APP),
false,
saverObserver,
)
events = sink
lastPublished = ignoring
}
override fun onCancel(arguments: Any?) {
events ?: return
events = null
context.unregisterReceiver(allowlistReceiver)
context.contentResolver.unregisterContentObserver(saverObserver)
}
private fun publish() {
val sink = events ?: return
val ignoring = isIgnoringOptimizations()
if (ignoring == lastPublished) {
return
}
lastPublished = ignoring
sink.success(ignoring)
}
private fun resolve() {
publish()
pendingRequest.resolve(Unit)
}
private fun requestIgnoreOptimizations(callback: (Unit) -> Unit) {
val activity = activityBinding?.activity
if (activity == null || isIgnoringOptimizations()) {
callback(Unit)
return
}
pendingRequest.replace(callback, supersededValue = Unit)
val pages = if (isXiaomi) saverPages() else optimizationPages()
if (pages.none { launch(activity, it) }) {
resolve()
}
}
@SuppressLint("BatteryLife")
private fun optimizationPages(): List<Intent> {
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
val prompt = Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
"package:${context.packageName}".toUri(),
)
// Some ROMs drop the direct prompt; the allowlist page still lets the user exempt the app.
val allowlist = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
return listOf(prompt, allowlist)
}
private fun saverPages(): List<Intent> {
val policy = Intent()
.setComponent(ComponentName(POWERKEEPER_PACKAGE, POWERKEEPER_POLICY_ACTIVITY))
.putExtra("package_name", context.packageName)
.putExtra(
"package_label",
context.applicationInfo.loadLabel(context.packageManager).toString(),
)
val details = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
"package:${context.packageName}".toUri(),
)
return listOf(policy, details)
}
private fun launch(activity: Activity, page: Intent): Boolean = try {
@Suppress("DEPRECATION")
activity.startActivityForResult(page, REQUEST_CODE)
true
} catch (_: ActivityNotFoundException) {
false
} catch (_: SecurityException) {
false
}
// On Xiaomi the battery saver is what counts: "No restrictions" also allowlists the
// app, the allowlist alone does not stop PowerKeeper freezing it, and PowerKeeper
// drops a running VPN from the allowlist while the list follows the user's policy.
private fun isIgnoringOptimizations(): Boolean {
if (isXiaomi) {
return context.packageName in noRestrictionApps()
}
return context.getSystemService(PowerManager::class.java)
?.isIgnoringBatteryOptimizations(context.packageName) ?: false
}
// PowerKeeper's comma-separated "No restrictions" packages, kept for user 0 only.
private fun noRestrictionApps(): List<String> {
val packages = try {
Settings.System.getString(context.contentResolver, MILLET_NO_RESTRICT_APP)
} catch (_: SecurityException) {
null
}
return packages?.split(',')?.map { it.trim() }.orEmpty()
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(activityResultListener)
}
override fun onDetachedFromActivityForConfigChanges() {
detachFromActivity()
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
onAttachedToActivity(binding)
}
override fun onDetachedFromActivity() {
detachFromActivity()
resolve()
}
private fun detachFromActivity() {
activityBinding?.removeActivityResultListener(activityResultListener)
activityBinding = null
}
private companion object {
val isXiaomi = Build.MANUFACTURER.equals("xiaomi", ignoreCase = true)
// Every plugin on the activity sees every result; AppPlugin holds 1001-1004.
const val REQUEST_CODE = 1005
// PowerManager keeps this action @hide, yet the system broadcasts it to
// registered receivers of the system user whenever the allowlist changes.
const val ACTION_POWER_SAVE_WHITELIST_CHANGED =
"android.os.action.POWER_SAVE_WHITELIST_CHANGED"
const val MILLET_NO_RESTRICT_APP = "MILLET_NO_RESTRICT_APP"
const val POWERKEEPER_PACKAGE = "com.miui.powerkeeper"
const val POWERKEEPER_POLICY_ACTIVITY = "com.miui.powerkeeper.ui.HiddenAppsConfigActivity"
}
}
@@ -1,49 +1,145 @@
package com.follow.clash.plugins
import android.content.Context
import android.net.ConnectivityManager
import androidx.core.content.getSystemService
import com.follow.clash.GlobalState
import com.follow.clash.ServiceController
import com.follow.clash.ServiceState
import com.follow.clash.common.Components
import com.follow.clash.models.SharedState
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.StandardMethodCodec
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var messenger: BinaryMessenger
private lateinit var channel: MethodChannel
private lateinit var coreChannel: MethodChannel
private lateinit var scope: CoroutineScope
private val gson = Gson()
private var commandRevision = 0
private lateinit var flutterMethodChannel: MethodChannel
private lateinit var context: Context
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext
flutterMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "service")
flutterMethodChannel.setMethodCallHandler(this)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
messenger = binding.binaryMessenger
channel = MethodChannel(messenger, SERVICE_CHANNEL)
channel.setMethodCallHandler(this)
// Serial: watchRoute keeps the latest intent by the order calls reach the Core.
coreChannel = MethodChannel(
messenger,
CORE_CHANNEL,
StandardMethodCodec.INSTANCE,
messenger.makeBackgroundTaskQueue(),
)
coreChannel.setMethodCallHandler(::onCoreMethodCall)
}
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
flutterMethodChannel.setMethodCallHandler(null)
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
coreChannel.setMethodCallHandler(null)
scope.cancel()
ServiceController.setEventListener(null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
"init" -> {
GlobalState.getCurrentAppPlugin()?.requestNotificationsPermission(context)
GlobalState.initServiceEngine(context)
result.success(true)
}
"destroy" -> {
handleDestroy()
result.success(true)
}
else -> {
result.notImplemented()
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"init" -> initialize(result)
"shutdown" -> shutdown(result)
"getRunTime" -> getRunTime(result)
"syncState" -> syncState(call, result)
"start" -> start(call, result)
"stop" -> stop(call, result)
else -> result.notImplemented()
}
}
private fun handleDestroy() {
GlobalState.getCurrentVPNPlugin()?.stop()
GlobalState.destroyServiceEngine()
private fun onCoreMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"invokeMethod" -> invokeMethod(call, result)
else -> result.notImplemented()
}
}
}
private fun initialize(result: MethodChannel.Result) {
ServiceController.setEventListener(::sendEvent)
.onSuccess { result.success("") }
.onFailure { error -> result.success(error.message.orEmpty()) }
}
private fun shutdown(result: MethodChannel.Result) {
ServiceController.setEventListener(null)
result.success(true)
}
private fun invokeMethod(call: MethodCall, result: MethodChannel.Result) {
val data = call.arguments as? String
if (data == null) {
result.error("INVALID_ARGUMENT", "Method call payload must be a string", null)
return
}
ServiceController.invokeMethod(data) { response ->
result.success(response)
}.onFailure { error ->
result.error("CORE_ERROR", error.message, null)
}
}
private fun getRunTime(result: MethodChannel.Result) {
scope.launch {
result.success(ServiceState.awaitRunTime())
}
}
private fun syncState(call: MethodCall, result: MethodChannel.Result) {
val data = call.arguments as? String
val state = runCatching {
gson.fromJson(data, SharedState::class.java)
}.getOrNull()
if (state == null) {
result.success("Invalid shared state")
return
}
scope.launch {
ServiceState.syncSharedState(state)
result.success("")
}
}
private fun start(call: MethodCall, result: MethodChannel.Result) {
commandRevision = call.arguments as? Int ?: commandRevision
ServiceState.requestStart()
result.success(true)
}
private fun stop(call: MethodCall, result: MethodChannel.Result) {
commandRevision = call.arguments as? Int ?: commandRevision
ServiceState.requestStop()
result.success(true)
}
// Read on the platform thread, where start and stop arrive: revision and intent are one instant.
fun notifyStopped() {
scope.launch(Dispatchers.Main) {
if (!ServiceState.isRunRequested) {
channel.invokeMethod("stopped", commandRevision)
}
}
}
private fun sendEvent(value: ByteArray) {
val message = StandardMethodCodec.INSTANCE.encodeMethodCall(MethodCall("event", value))
scope.launch(Dispatchers.Main) {
messenger.send(SERVICE_CHANNEL, message)
}
}
private companion object {
val SERVICE_CHANNEL = "${Components.PACKAGE_NAME}/service"
val CORE_CHANNEL = "${Components.PACKAGE_NAME}/core"
}
}
@@ -1,38 +1,29 @@
package com.follow.clash.plugins
import com.follow.clash.common.Components
import com.follow.clash.invokeMethodOnMainThread
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class TilePlugin(private val onStart: (() -> Unit)? = null, private val onStop: (() -> Unit)? = null) : FlutterPlugin,
MethodChannel.MethodCallHandler {
class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "tile")
channel =
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/tile")
channel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
handleDetached()
channel.setMethodCallHandler(null)
}
fun handleStart() {
onStart?.let { it() }
channel.invokeMethod("start", null)
channel.invokeMethodOnMainThread("start")
}
fun handleStop() {
channel.invokeMethod("stop", null)
onStop?.let { it() }
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
result.notImplemented()
}
private fun handleDetached() {
channel.invokeMethod("detached", null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {}
}
}
@@ -1,265 +0,0 @@
package com.follow.clash.plugins
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.IBinder
import androidx.core.content.getSystemService
import com.follow.clash.BaseServiceInterface
import com.follow.clash.GlobalState
import com.follow.clash.RunState
import com.follow.clash.extensions.getProtocol
import com.follow.clash.extensions.resolveDns
import com.follow.clash.services.FlClashService
import com.follow.clash.services.FlClashVpnService
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.InetSocketAddress
import kotlin.concurrent.withLock
import com.follow.clash.models.Process
import com.follow.clash.models.VpnOptions
class VpnPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var flutterMethodChannel: MethodChannel
private lateinit var context: Context
private var flClashService: BaseServiceInterface? = null
private lateinit var options: VpnOptions
private lateinit var scope: CoroutineScope
private val connectivity by lazy {
context.getSystemService<ConnectivityManager>()
}
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
flClashService = when (service) {
is FlClashVpnService.LocalBinder -> service.getService()
is FlClashService.LocalBinder -> service.getService()
else -> throw Exception("invalid binder")
}
start()
}
override fun onServiceDisconnected(arg: ComponentName) {
flClashService = null
}
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(Dispatchers.Default)
context = flutterPluginBinding.applicationContext
scope.launch {
registerNetworkCallback()
}
flutterMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "vpn")
flutterMethodChannel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
unRegisterNetworkCallback()
flutterMethodChannel.setMethodCallHandler(null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"start" -> {
val data = call.argument<String>("data")
options = Gson().fromJson(data, VpnOptions::class.java)
when (options.enable) {
true -> handleStartVpn()
false -> start()
}
result.success(true)
}
"stop" -> {
stop()
result.success(true)
}
"setProtect" -> {
val fd = call.argument<Int>("fd")
if (fd != null) {
if (flClashService is FlClashVpnService) {
(flClashService as FlClashVpnService).protect(fd)
}
result.success(true)
} else {
result.success(false)
}
}
"startForeground" -> {
val title = call.argument<String>("title") as String
val content = call.argument<String>("content") as String
startForeground(title, content)
result.success(true)
}
"resolverProcess" -> {
val data = call.argument<String>("data")
val process =
if (data != null) Gson().fromJson(
data,
Process::class.java
) else null
val metadata = process?.metadata
if (metadata == null) {
result.success(null)
return
}
val protocol = metadata.getProtocol()
if (protocol == null) {
result.success(null)
return
}
scope.launch {
withContext(Dispatchers.Default) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
result.success(null)
return@withContext
}
val src = InetSocketAddress(metadata.sourceIP, metadata.sourcePort)
val dst = InetSocketAddress(
metadata.destinationIP.ifEmpty { metadata.host },
metadata.destinationPort
)
val uid = try {
connectivity?.getConnectionOwnerUid(protocol, src, dst)
} catch (_: Exception) {
null
}
if (uid == null || uid == -1) {
result.success(null)
return@withContext
}
val packages = context.packageManager?.getPackagesForUid(uid)
result.success(packages?.first())
}
}
}
else -> {
result.notImplemented()
}
}
}
private fun handleStartVpn() {
GlobalState.getCurrentAppPlugin()?.requestVpnPermission(context) {
start()
}
}
fun requestGc() {
flutterMethodChannel.invokeMethod("gc", null)
}
val networks = mutableSetOf<Network>()
fun onUpdateNetwork() {
val dns = networks.flatMap { network ->
connectivity?.resolveDns(network) ?: emptyList()
}
.toSet()
.joinToString(",")
scope.launch {
withContext(Dispatchers.Main) {
flutterMethodChannel.invokeMethod("dnsChanged", dns)
}
}
// if (flClashService is FlClashVpnService) {
// val network = networks.maxByOrNull { net ->
// connectivity?.getNetworkCapabilities(net)?.let { cap ->
// TRANSPORT_PRIORITY.indexOfFirst { cap.hasTransport(it) }
// } ?: -1
// }
// network?.let {
// (flClashService as FlClashVpnService).updateUnderlyingNetworks(arrayOf(network))
// }
// }
}
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networks.add(network)
onUpdateNetwork()
}
override fun onLost(network: Network) {
networks.remove(network)
onUpdateNetwork()
}
}
private val request = NetworkRequest.Builder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
}.build()
private fun registerNetworkCallback() {
networks.clear()
connectivity?.registerNetworkCallback(request, callback)
}
private fun unRegisterNetworkCallback() {
connectivity?.unregisterNetworkCallback(callback)
networks.clear()
onUpdateNetwork()
}
private fun startForeground(title: String, content: String) {
GlobalState.runLock.withLock {
if (GlobalState.runState.value != RunState.START) return
flClashService?.startForeground(title, content)
}
}
private fun start() {
if (flClashService == null) {
bindService()
return
}
GlobalState.runLock.withLock {
if (GlobalState.runState.value == RunState.START) return
GlobalState.runState.value = RunState.START
val fd = flClashService?.start(options)
flutterMethodChannel.invokeMethod(
"started",
fd
)
}
}
fun stop() {
GlobalState.runLock.withLock {
if (GlobalState.runState.value == RunState.STOP) return
GlobalState.runState.value = RunState.STOP
flClashService?.stop()
}
GlobalState.destroyServiceEngine()
}
private fun bindService() {
val intent = when (options.enable) {
true -> Intent(context, FlClashVpnService::class.java)
false -> Intent(context, FlClashService::class.java)
}
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
}
@@ -1,102 +0,0 @@
package com.follow.clash.services
import android.annotation.SuppressLint
import android.app.Notification.FOREGROUND_SERVICE_IMMEDIATE
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.os.Binder
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.follow.clash.BaseServiceInterface
import com.follow.clash.MainActivity
import com.follow.clash.models.VpnOptions
class FlClashService : Service(), BaseServiceInterface {
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): FlClashService = this@FlClashService
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun onUnbind(intent: Intent?): Boolean {
return super.onUnbind(intent)
}
private val CHANNEL = "FlClash"
private val notificationId: Int = 1
private val notificationBuilder: NotificationCompat.Builder by lazy {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
with(NotificationCompat.Builder(this, CHANNEL)) {
setSmallIcon(com.follow.clash.R.drawable.ic_stat_name)
setContentTitle("FlClash")
setContentIntent(pendingIntent)
setCategory(NotificationCompat.CATEGORY_SERVICE)
priority = NotificationCompat.PRIORITY_MIN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
}
setOngoing(true)
setShowWhen(false)
setOnlyAlertOnce(true)
setAutoCancel(true)
}
}
override fun start(options: VpnOptions) = 0
override fun stop() {
stopSelf()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
@SuppressLint("ForegroundServiceType", "WrongConstant")
override fun startForeground(title: String, content: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
var channel = manager?.getNotificationChannel(CHANNEL)
if (channel == null) {
channel =
NotificationChannel(CHANNEL, "FlClash", NotificationManager.IMPORTANCE_LOW)
manager?.createNotificationChannel(channel)
}
}
val notification =
notificationBuilder.setContentTitle(title).setContentText(content).build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(notificationId, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(notificationId, notification)
}
}
}
@@ -1,89 +0,0 @@
package com.follow.clash.services
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.annotation.RequiresApi
import androidx.lifecycle.Observer
import com.follow.clash.GlobalState
import com.follow.clash.RunState
import com.follow.clash.TempActivity
@RequiresApi(Build.VERSION_CODES.N)
class FlClashTileService : TileService() {
private val observer = Observer<RunState> { runState ->
updateTile(runState)
}
private fun updateTile(runState: RunState) {
if (qsTile != null) {
qsTile.state = when (runState) {
RunState.START -> Tile.STATE_ACTIVE
RunState.PENDING -> Tile.STATE_UNAVAILABLE
RunState.STOP -> Tile.STATE_INACTIVE
}
qsTile.updateTile()
}
}
override fun onStartListening() {
super.onStartListening()
GlobalState.runState.value?.let { updateTile(it) }
GlobalState.runState.observeForever(observer)
}
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun activityTransfer() {
val intent = Intent(this, TempActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(pendingIntent)
} else {
startActivityAndCollapse(intent)
}
}
override fun onClick() {
super.onClick()
activityTransfer()
if (GlobalState.runState.value == RunState.STOP) {
GlobalState.runState.value = RunState.PENDING
val tilePlugin = GlobalState.getCurrentTilePlugin()
if (tilePlugin != null) {
tilePlugin.handleStart()
} else {
GlobalState.initServiceEngine(applicationContext)
}
} else if (GlobalState.runState.value == RunState.START) {
GlobalState.runState.value = RunState.PENDING
GlobalState.getCurrentTilePlugin()?.handleStop()
}
}
override fun onDestroy() {
GlobalState.runState.removeObserver(observer)
super.onDestroy()
}
}
@@ -1,219 +0,0 @@
package com.follow.clash.services
import android.annotation.SuppressLint
import android.app.Notification.FOREGROUND_SERVICE_IMMEDIATE
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.net.Network
import android.net.ProxyInfo
import android.net.VpnService
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.Parcel
import android.os.RemoteException
import androidx.core.app.NotificationCompat
import com.follow.clash.BaseServiceInterface
import com.follow.clash.GlobalState
import com.follow.clash.MainActivity
import com.follow.clash.R
import com.follow.clash.TempActivity
import com.follow.clash.extensions.toCIDR
import com.follow.clash.models.AccessControlMode
import com.follow.clash.models.VpnOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class FlClashVpnService : VpnService(), BaseServiceInterface {
override fun onCreate() {
super.onCreate()
GlobalState.initServiceEngine(applicationContext)
}
override fun start(options: VpnOptions): Int {
return with(Builder()) {
if (options.ipv4Address.isNotEmpty()) {
val cidr = options.ipv4Address.toCIDR()
addAddress(cidr.address, cidr.prefixLength)
addRoute("0.0.0.0", 0)
}
if (options.ipv6Address.isNotEmpty()) {
val cidr = options.ipv6Address.toCIDR()
addAddress(cidr.address, cidr.prefixLength)
addRoute("::", 0)
}
addDnsServer(options.dnsServerAddress)
setMtu(9000)
options.accessControl?.let { accessControl ->
when (accessControl.mode) {
AccessControlMode.acceptSelected -> {
(accessControl.acceptList + packageName).forEach {
addAllowedApplication(it)
}
}
AccessControlMode.rejectSelected -> {
(accessControl.rejectList - packageName).forEach {
addDisallowedApplication(it)
}
}
}
}
setSession("FlClash")
setBlocking(false)
if (Build.VERSION.SDK_INT >= 29) {
setMetered(false)
}
if (options.allowBypass) {
allowBypass()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && options.systemProxy) {
setHttpProxy(
ProxyInfo.buildDirectProxy(
"127.0.0.1",
options.port,
options.bypassDomain
)
)
}
establish()?.detachFd()
?: throw NullPointerException("Establish VPN rejected by system")
}
}
fun updateUnderlyingNetworks(networks: Array<Network>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
this.setUnderlyingNetworks(networks)
}
}
override fun stop() {
stopSelf()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
private val CHANNEL = "FlClash"
private val notificationId: Int = 1
private val notificationBuilder: NotificationCompat.Builder by lazy {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
val stopIntent = Intent(this, TempActivity::class.java)
stopIntent.action = "com.follow.clash.action.STOP"
stopIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
val stopPendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
stopIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this,
0,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
with(NotificationCompat.Builder(this, CHANNEL)) {
setSmallIcon(R.drawable.ic_stat_name)
setContentTitle("FlClash")
setContentIntent(pendingIntent)
setCategory(NotificationCompat.CATEGORY_SERVICE)
priority = NotificationCompat.PRIORITY_MIN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
}
setOngoing(true)
setShowWhen(false)
setOnlyAlertOnce(true)
setAutoCancel(true)
addAction(0, "Stop", stopPendingIntent);
}
}
@SuppressLint("ForegroundServiceType", "WrongConstant")
override fun startForeground(title: String, content: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
var channel = manager?.getNotificationChannel(CHANNEL)
if (channel == null) {
channel =
NotificationChannel(CHANNEL, "FlClash", NotificationManager.IMPORTANCE_LOW)
manager?.createNotificationChannel(channel)
}
}
val notification =
notificationBuilder.setContentTitle(title).setContentText(content).build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(notificationId, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(notificationId, notification)
}
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
GlobalState.getCurrentVPNPlugin()?.requestGc()
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): FlClashVpnService = this@FlClashVpnService
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
try {
val isSuccess = super.onTransact(code, data, reply, flags)
if (!isSuccess) {
CoroutineScope(Dispatchers.Main).launch {
GlobalState.getCurrentTilePlugin()?.handleStop()
}
}
return isSuccess
} catch (e: RemoteException) {
throw e
}
}
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun onUnbind(intent: Intent?): Boolean {
return super.onUnbind(intent)
}
override fun onDestroy() {
stop()
super.onDestroy()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,25 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="240"
android:viewportHeight="240">
<group android:scaleX="0.924"
android:scaleY="0.924"
android:translateX="9.12"
android:translateY="9.12">
<group android:scaleX="0.63461536"
android:scaleY="0.63461536"
android:translateX="45.96154"
android:translateY="43.846153">
<path
android:pathData="M60.65,89.6L154.18,35.6A18,18 107.59,0 1,178.77 42.19L178.77,42.19A18,18 107.59,0 1,172.18 66.78L78.65,120.78A18,18 106.67,0 1,54.06 114.19L54.06,114.19A18,18 106.67,0 1,60.65 89.6z"
android:fillColor="#6666FB"/>
<path
android:pathData="M84.65,131.17L131.42,104.17A18,18 107.83,0 1,156 110.76L156,110.76A18,18 107.83,0 1,149.42 135.35L102.65,162.35A18,18 106.67,0 1,78.06 155.76L78.06,155.76A18,18 106.67,0 1,84.65 131.17z"
android:fillColor="#336AB6"/>
<path
android:pathData="M108.65,172.74L108.65,172.74A18,18 116.03,0 1,133.24 179.33L133.24,179.33A18,18 116.03,0 1,126.65 203.92L126.65,203.92A18,18 116.03,0 1,102.06 197.33L102.06,197.33A18,18 116.03,0 1,108.65 172.74z"
android:fillColor="#5CA8E9"/>
android:width="108dp"
android:height="108dp"
android:viewportWidth="240"
android:viewportHeight="240">
<group android:scaleX="0.924"
android:scaleY="0.924"
android:translateX="9.12"
android:translateY="9.12">
<group android:scaleX="0.63461536"
android:scaleY="0.63461536"
android:translateX="45.96154"
android:translateY="43.846153">
<path
android:pathData="M60.65,89.6L154.18,35.6A18,18 107.59,0 1,178.77 42.19L178.77,42.19A18,18 107.59,0 1,172.18 66.78L78.65,120.78A18,18 106.67,0 1,54.06 114.19L54.06,114.19A18,18 106.67,0 1,60.65 89.6z"
android:fillColor="#6666FB"/>
<path
android:pathData="M84.65,131.17L131.42,104.17A18,18 107.83,0 1,156 110.76L156,110.76A18,18 107.83,0 1,149.42 135.35L102.65,162.35A18,18 106.67,0 1,78.06 155.76L78.06,155.76A18,18 106.67,0 1,84.65 131.17z"
android:fillColor="#336AB6"/>
<path
android:pathData="M108.65,172.74L108.65,172.74A18,18 116.03,0 1,133.24 179.33L133.24,179.33A18,18 116.03,0 1,126.65 203.92L126.65,203.92A18,18 116.03,0 1,102.06 197.33L102.06,197.33A18,18 116.03,0 1,108.65 172.74z"
android:fillColor="#5CA8E9"/>
</group>
</group>
</group>
</vector>
@@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="240"
android:viewportHeight="240">
<group
android:scaleX="0.9"
android:scaleY="0.9"
android:translateX="15.23"
android:translateY="12.22">
<path
android:fillColor="#6666FB"
android:pathData="M60.65,89.6L154.18,35.6A18,18 107.59,0 1,178.77 42.19L178.77,42.19A18,18 107.59,0 1,172.18 66.78L78.65,120.78A18,18 106.67,0 1,54.06 114.19L54.06,114.19A18,18 106.67,0 1,60.65 89.6z" />
<path
android:fillColor="#336AB6"
android:pathData="M84.65,131.17L131.42,104.17A18,18 107.83,0 1,156 110.76L156,110.76A18,18 107.83,0 1,149.42 135.35L102.65,162.35A18,18 106.67,0 1,78.06 155.76L78.06,155.76A18,18 106.67,0 1,84.65 131.17z" />
<path
android:fillColor="#5CA8E9"
android:pathData="M108.65,172.74L108.65,172.74A18,18 116.03,0 1,133.24 179.33L133.24,179.33A18,18 116.03,0 1,126.65 203.92L126.65,203.92A18,18 116.03,0 1,102.06 197.33L102.06,197.33A18,18 116.03,0 1,108.65 172.74z" />
</group>
</vector>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@color/shortcut_background"
android:pathData="M24,2 A22,22 0 1 1 24,46 A22,22 0 1 1 24,2 Z" />
<group
android:scaleX="1.35"
android:scaleY="1.35"
android:translateX="7.8"
android:translateY="7.8">
<path
android:fillColor="#FFFFFF"
android:pathData="M8.30,7.74 A1.2,1.2 0 0 1 10.12,6.71 L17.12,10.98 A1.2,1.2 0 0 1 17.12,13.02 L10.12,17.29 A1.2,1.2 0 0 1 8.30,16.26 Z"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.8" />
</group>
</vector>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@color/shortcut_background"
android:pathData="M24,2 A22,22 0 1 1 24,46 A22,22 0 1 1 24,2 Z" />
<group
android:scaleX="1.35"
android:scaleY="1.35"
android:translateX="7.8"
android:translateY="7.8">
<path
android:fillColor="#FFFFFF"
android:pathData="M14.90,7.00 A1.1,1.1 0 0 1 16.00,5.90 L16.00,5.90 A1.1,1.1 0 0 1 17.10,7.00 L17.10,17.00 A1.1,1.1 0 0 1 16.00,18.10 L16.00,18.10 A1.1,1.1 0 0 1 14.90,17.00 Z M6.90,7.00 A1.1,1.1 0 0 1 8.00,5.90 L8.00,5.90 A1.1,1.1 0 0 1 9.10,7.00 L9.10,17.00 A1.1,1.1 0 0 1 8.00,18.10 L8.00,18.10 A1.1,1.1 0 0 1 6.90,17.00 Z"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.8" />
</group>
</vector>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@color/shortcut_background"
android:pathData="M24,2 A22,22 0 1 1 24,46 A22,22 0 1 1 24,2 Z" />
<group
android:scaleX="1.35"
android:scaleY="1.35"
android:translateX="7.8"
android:translateY="7.8">
<path
android:fillColor="#FFFFFF"
android:pathData="M2.90,12.00 A5.5,5.5 0 0 1 8.40,6.50 L15.60,6.50 A5.5,5.5 0 0 1 21.10,12.00 L21.10,12.00 A5.5,5.5 0 0 1 15.60,17.50 L8.40,17.50 A5.5,5.5 0 0 1 2.90,12.00 Z"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.8" />
<path
android:fillColor="@color/shortcut_background"
android:pathData="M8.4,9.2 A2.8,2.8 0 1 1 8.4,14.8 A2.8,2.8 0 1 1 8.4,9.2 Z" />
</group>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground_tv" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="fl_clash">FlClash</string>
<color name="shortcut_background">#6666FB</color>
</resources>

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