Compare commits

...

159 Commits

Author SHA1 Message Date
chen08209 1de388c91b chore: bump version to 0.8.99+2026092401 2026-09-24 13:37:01 +08:00
chen08209 8192cab171 docs(agents): correct stale facts and sharpen skill triggers
- Match the event queues, delay-test timeouts, restart worker, manager
  stack, CI gating, debug data directories and code_forge tag to the code.
- Resolve contradictions on test injection, comment density and rule
  restating, and drop the manager list that repeated the stack diagram.
- Note that Flutter stays on 3.47.x, widen the core-platform, ui-work and
  localization triggers and checks, and have the rust_api CLAUDE.md import
  the root AGENTS.md.
2026-09-24 13:36:41 +08:00
chen08209 74f97f0a73 feat(ui): roomier page bars and an elastic press on filled buttons
- Page bars are 64 tall, as Material 3's small top app bar, with 40 px
  filled buttons, a softer header fade, and one shared content inset.
- Filled buttons, filled icon buttons and FABs swell under a press like
  the app bar and dock instead of rippling.

Changelog: Page bars are roomier, and filled buttons and floating action buttons get an elastic press
2026-09-24 13:30:14 +08:00
chen08209 8215e62c9c feat(overwrite): override proxies and name what the core would refuse
Custom mode keeps its own proxies; validateProxies runs mihomo's
ParseProxy, so an entry is checked exactly as a setup would load it.
Each invalid entry says why, and saving refuses what the core would
reject. App-level providers and custom proxies ship hidden behind
FEATURE_CUSTOM_PROVIDERS and FEATURE_CUSTOM_PROXIES.

Changelog: Custom overrides name the exact problem in each proxy group and rule, and refuse to save a group the core would reject
2026-09-24 13:30:14 +08:00
chen08209 84e1284588 feat(ui): add a sidebar, snap sheets, shared record rows, and new dashboard cards
- The navigation rail becomes a collapsible sidebar; app bars and a
  mobile dock float over the page.
- Bottom sheets snap between detents under a floating header.
- Record lists share one row layout and one multi-word search.
- Proxies can hide timed-out nodes; the dashboard gains feed and
  outbound mode cards on an animated grid.
- Hidden pages pause work and per-frame animations stop.

Changelog: A sidebar and floating dock replace the navigation rail, sheets snap between half and full height, record lists share a new row layout and search, proxies can hide timed-out nodes, the dashboard gets feed and outbound mode cards, and the app uses less battery while hidden
2026-09-24 13:30:14 +08:00
chen08209 24f237eadc fix(backup,database): stage restores and enforce foreign keys
A restore applies nothing until the whole archive is read, and refuses a
backup newer than the app. Foreign keys are on so cascades run, and a
schema upgrade drops rows earlier deletes left behind.

Changelog: Restoring a backup applies all or nothing and refuses backups from a newer app, and deleting profiles or rules no longer leaves stale data behind
2026-09-24 13:30:14 +08:00
chen08209 97af21de6d feat(settings,hotkey,tray): group settings into a General page and rework hotkeys
Changelog: Basic and app settings merge into one General page, hotkeys cover mode switching, delay test, profile update and exit, and the tray shows each item's hotkey, node delays and a delay test entry
2026-09-24 13:30:14 +08:00
chen08209 35b4e54f05 feat(providers,scripts): add app-level providers and manage override scripts
Override scripts keep their import URL and update from it. The code
editor moves to code_forge, and this release's localized strings are
regenerated here.

Changelog: Override scripts can be imported from a URL or file, updated from their URL, and reordered
2026-09-24 13:30:14 +08:00
chen08209 e30f60a8b8 feat(dns,config): log DNS queries and override only the keys picked
The DNS log lists the Core's own resolutions and who asked. DNS and NTP
overrides start empty and change only the keys the user picks.

Changelog: A DNS query log, a DNS override that changes only the keys you pick, new NTP settings, the mips TUN stack as the new default, and process lookup off by default
2026-09-24 13:30:14 +08:00
chen08209 ed9cb1ef3d feat(ui): draw the app's own glyphs and take colors from the scheme
Material icons and the icon font give way to stroke glyphs in lib/icons.
Status colors, scrims and empty states follow the color scheme, and
every control is reachable by remote on TV.

Changelog: New stroke icons and empty states that follow the theme, and every control is reachable with a TV remote
2026-09-24 13:30:14 +08:00
chen08209 a3e406c97a feat(core,dashboard): check services and the outbound IP through the Core
Checks run on the route the Core publishes, so results match the path
traffic takes. The TUN stack defaults to mips, and NTP no longer sets
the Android system clock, which killed the app.

Changelog: The dashboard checks streaming and AI services through the proxy, tapping the outbound IP shows its quality and risk, and the memory card splits app and Core usage
2026-09-24 12:36:04 +08:00
chen08209 eefd35a6bf feat(platform): move desktop windows to plugins/window and add a safe mode build
plugins/window replaces the window_manager fork on Windows, Linux and
macOS. A safe mode build leaves the system proxy, TUN and DNS alone so
it can run beside the user's instance. Also uploads Android native
symbols to Crashlytics and releases only from the public repository.

Changelog: skip
2026-09-24 12:36:04 +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
chen08209 6de89d7de4 Optimize proxies page
Fix ua issues

Optimize more details
2024-07-25 13:57:37 +08:00
chen08209 c36df8cb4a Fix windows build error 2024-07-22 15:49:59 +08:00
chen08209 530817b268 Update app icon
Fix desktop backup error
2024-07-22 15:05:05 +08:00
chen08209 721dd20251 Optimize request ua
Change android icon

Optimize dashboard
2024-07-20 18:05:49 +08:00
chen08209 f2aa8851ae Remove request validate certificate
Sync core
2024-07-18 21:28:27 +08:00
chen08209 ec2890cab2 Fix windows error 2024-07-18 17:27:29 +08:00
chen08209 ca946c1b06 Fix setup.dart error 2024-07-18 16:39:28 +08:00
chen08209 3bc3172723 Fix android system proxy not effective
Add macos arm64
2024-07-18 16:33:53 +08:00
chen08209 82be4cc45f Optimize proxies page
Support mouse drag scroll

Adjust desktop ui
2024-07-17 14:52:15 +08:00
chen08209 2c3f4ae8a8 Revert "Fix android vpn issues"
This reverts commit 891977408e.
2024-07-17 14:51:52 +08:00
chen08209 891977408e Fix android vpn issues 2024-07-15 16:19:58 +08:00
chen08209 5292f34e8d Fix android vpn issues 2024-07-15 16:18:51 +08:00
chen08209 1c54db6bf3 Rollback partial modification 2024-07-15 16:14:19 +08:00
chen08209 a4b5f4abdb Fix the problem that ui can't be synchronized when android vpn is occupied by an external
Override default socksPort,port
2024-07-15 13:47:06 +08:00
chen08209 aa4ffbe4fb Fix fab issues 2024-07-14 01:37:44 +08:00
chen08209 3d25298639 Update version 2024-07-14 00:05:42 +08:00
chen08209 1765576d09 Fix the problem that vpn cannot be started in some cases
Fix the problem that geodata url does not take effect
2024-07-14 00:01:18 +08:00
chen08209 2dd45062f1 Update ua
Fix change outbound mode without check ip issues
2024-07-13 20:43:28 +08:00
chen08209 c6407984ac Separate android ui and vpn 2024-07-13 20:43:21 +08:00
chen08209 53af86238e Fix url validate issues 2
Add android hidden from the recent task

Add geoip file

Support modify geoData URL
2024-07-13 20:43:18 +08:00
chen08209 b20d9edec2 Fix url validate issues
Fix check ip performance problem

Optimize resources page
2024-07-07 13:37:08 +08:00
chen08209 5c3a0c576d Add ua selector
Support modify test url

Optimize android proxy

Fix the error that async proxy provider could not selected the proxy
2024-07-04 09:55:06 +08:00
chen08209 6dcb466fd3 Fix android proxy error 2024-07-01 20:57:24 +08:00
chen08209 acbcec358b Fix submit error 2024-07-01 19:51:11 +08:00
chen08209 a923549ddf Add windows tun
Optimize android proxy

Optimize change profile

Update application ua

Optimize delay test
2024-07-01 19:41:57 +08:00
chen08209 07bd21580b Fix android repeated request notification issues 2024-06-28 21:16:47 +08:00
chen08209 57ceb64a5e Fix memory overflow issues 2024-06-28 07:49:06 +08:00
1241 changed files with 291928 additions and 65219 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.
+981
View File
@@ -0,0 +1,981 @@
# Architecture
## Core Integration
The Go proxy core in `core/` operates in two modes.
Android lib mode:
- Go core is compiled as a C shared library, `libclash.so`, through `go build -buildmode=c-shared` with CGO.
- The Android `:core` module owns JNI access to the in-process library. Flutter crosses the `${packageName}/service`
MethodChannel through `lib/plugins/service.dart` and Android's `ServicePlugin` rather than talking to JNI directly.
- `lib/core/lib.dart` (`CoreLib`) implements the shared Core interface, gates method calls on its connection completer,
initializes and synchronizes Android shared state, and closes the native service path exactly once.
- Because Core is in the application process on Android, application RSS already includes Core memory.
Desktop core mode:
- Go core runs as a separate process with `CGO_ENABLED=0`.
- `rust_api`'s `ipc` module provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe
on Windows. Dart owns the transport state, RPC correlation, process ownership, and lifecycle convergence above those
primitives.
- `lib/core/service.dart` (`CoreService`) is the composition root. It wires the IPC transport, launcher selection,
lifecycle controller, RPC client, and crash-event bridge, and holds no lifecycle logic of its own.
- `lib/core/desktop/transport.dart` converts native IPC frames into ready, connected, disconnected, failed, and data
events. A replaceable binding keeps RPC subscriptions stable when a failed or stale transport must be rebuilt.
- `lib/core/desktop/rpc_client.dart` owns request IDs and pending completers, waits up to 10 seconds for a connection,
applies a three-minute default method timeout, unwraps `CoreMethodResponse`, and fails all pending calls when transport
disconnects or closes.
- `lib/core/desktop/lifecycle.dart` serializes process intents and owns the authoritative desktop state machine.
- `lib/core/desktop/launcher.dart` abstracts direct child-process and privileged Helper ownership through idempotent process
leases. `lib/core/desktop/helper_client.dart` is the typed local HTTP client for the privileged Helper.
`lib/core/controller.dart` (`CoreController`) selects the implementation based on platform. `lib/core/interface.dart` defines the shared `CoreHandlerInterface`.
Key Go core files:
- `core/hub.go`: handler functions.
- `core/method.go`: MethodChannel-style method-call dispatch and response envelopes.
- `core/message.go`: non-blocking state/priority/bulk event queues and bounded message batching.
- `core/lib.go`: CGO exports.
- `core/server.go`: desktop socket/named-pipe client and framed message forwarding.
## DNS Override
`PatchClashConfig.dns` holds a full `Dns` model, but only the keys listed in `dnsOverrideKeys` reach a profile.
`DnsOverrideKey` names one Clash key each, with `fallback-filter.*` entries for the nested filter, and covers every
yaml key of mihomo's `RawDNS` and `RawFallbackFilter`; `test/models/dns_override_test.dart` reads
`core/Clash.Meta/config/config.go` and fails when a core bump adds or drops one. `Dns.overrideJson` builds the
fragment in model order and `mergeDnsOverride` merges it one level deep, so a partial fallback filter keeps the
profile's other filter keys. `_makeRealProfileTask` applies the fragment when `overrideDns` is on; for a profile whose
`dns.enable` is not true it first fills `defaultDns` for `baselineDnsOverrideKeys` and then applies the fragment
whatever the toggle says. The DNS page's quick edit opens `overrideYaml` in an `EditorPage` and applies it on the way
back through `applyOverrideYaml`, which rebuilds the key set from the keys the document names and rejects unknown keys
or bad values; a rejected document can only be discarded or fixed. The page has no reset, because there is nothing to
reset to.
A fresh config overrides nothing: `dnsOverrideKeys` starts empty and the profile's own section stands until an entry
is added. `baselineDnsOverrideKeys` is the separate, smaller thing the core is never without — the `enable`,
`enhanced-mode: fake-ip` and `nameserver` given to a profile that brings no enabled DNS section of its own, so the
core always resolves. Every other `Dns` field starts at mihomo's own default, which `UnmarshalRawConfig` starts from,
so adding an entry changes nothing until it is edited; `listen` and `fake-ip-range6`, which mihomo leaves off, start
from a working value instead. A config saved before the key list existed overrode the whole section as the model held
it then, so `PatchClashConfig.fromJson` gives it those twenty keys rather than the empty set.
## NTP Override
`PatchClashConfig.ntp` mirrors the DNS shape: a full `Ntp` model of which only the keys in `ntpOverrideKeys` reach a
profile, an empty set on a fresh config, and one `NtpOverrideKey` per yaml key of mihomo's `RawNTP`;
`test/models/ntp_override_test.dart` reads `core/Clash.Meta/config/config.go` and fails when a core bump adds, drops
or redefaults one. The section is flat, so `_makeRealProfileTask` spreads the fragment over the profile's own `ntp`
map instead of the one-level merge DNS needs, and writes nothing at all while `overrideNtp` is off. `interval` is
minutes, which is what `ReCreateNTPService` multiplies. The page carries the same add sheet and quick edit as DNS.
Safe mode forces `write-to-system` off on whatever `ntp` section the profile carries, because setting the system clock
is a host change; mihomo's own `loadConfig` already drops it on Android, where the seccomp policy kills the process
for `settimeofday`.
## Listener Exposure
`_makeRealProfileTask` in `lib/common/task.dart` writes `mixed-port`, `allow-lan`, and `external-controller` into every
generated profile. With `allow-lan` off, mihomo's `genAddr` binds each listener to `127.0.0.1`; the external controller is
`ExternalControllerStatus.close` by default and never binds anywhere but loopback.
The loopback listener takes local connections without authentication by default: mandatory credentials would lock out
every external local consumer a proxy client exists for. The binding only guarantees traffic originates on the device;
it does not isolate programs already running there, so on Android any app holding `INTERNET` can use the proxy and learn
the outbound IP (#1934). The opt-in answer is the local authentication setting (`NetworkProps.authentication`), which spans
four paths that must stay in sync: `_makeRealProfileTask` writes the credentials into `authentication` and force-clears
`skip-auth-prefixes` so a profile cannot silently exempt loopback; `UpdateParams.authentication` applies the same list
to a running core; `FlClashHttpOverrides` sends the credentials with the app's own proxied requests; and `sharedState`
withholds the Android VPN system proxy declaration because `ProxyInfo` cannot carry credentials (TUN still captures that
traffic). Desktop system proxy is deliberately not gated — dropping it would leak traffic direct, so the setting's
description tells users to supply credentials manually instead.
### Interface Name Modes
The same task also writes `interface-name` per `PatchClashConfig.interfaceNameMode`: `clear` (default) forces it empty so a
subscription's value cannot survive into the generated profile, `follow` leaves whatever the profile YAML already has, and
`custom` writes the user's `interfaceName`. mihomo ignores `interface-name` on Android whenever `dialer.DefaultSocketHook`
is installed (`core/lib.go` `installHooks`, vendored `dialer.go`), so `NetworkListView` in `lib/views/config/network.dart`
shows the mode picker and text field only on desktop.
## Lifecycle Ownership And Convergence
### Shared Flutter Layer
`CoreController.start()`, `restart()`, `stop()`, and `close()` are the only shared lifecycle facade. `close()` is terminal;
callers must not try to reuse a closed platform implementation.
`CoreAction` in `lib/providers/actions/core.dart` owns the user-facing Core status and setup sequence:
- `startCore()` publishes `connecting`, starts the platform Core, publishes `connected`, then initializes Core state. A
startup error publishes `disconnected` and displays the error.
- `restartCore()` coalesces overlapping callers behind one worker. `_requestedRestartRevision` records newer requests.
After the lifecycle restart, the worker reapplies running state or the profile, rereading `isStartProvider` on every
pass, until it has consumed the latest revision, so the newest start intent wins.
- The provider is an orchestration and presentation layer, not a process owner. Platform lifecycle code remains responsible
for determining whether a Core process/service is actually running.
Application exit is centralized in `SystemAction` and `SystemExitCoordinator`:
1. Optionally save config and clean up DNS, system proxy, and tray resources in parallel.
2. Close the desktop window.
3. Call terminal `CoreController.close()`.
4. Exit the application exactly once.
The coordinator is idempotent, continues later cleanup steps after an earlier error, preserves the first error for the
caller, and uses a three-second watchdog as an emergency application-exit path. `Application.dispose()` and
`CoreManager.onCrash()` do not independently destroy Core; this avoids competing shutdown owners.
### Desktop Lifecycle
`DesktopCoreLifecycle` is a latest-desired-intent reconciler, not a queue that blindly executes every request:
- Public intents receive monotonically increasing revisions and target running, restarted, stopped, or closed.
- Observable phases are `idle`, `starting`, `running`, `stopping`, `failed`, and `closed`.
- A completed command reports `applied`, `coalesced`, or `superseded`, allowing callers and tests to distinguish a command
that won from one satisfied or replaced by a newer compatible intent.
- Startup opens or replaces the IPC transport, resolves a launcher, generates a 128-bit lowercase hexadecimal session ID,
launches Core, and waits for the matching connection. Windows additionally verifies that the named-pipe peer PID equals
the process PID returned by the Helper lease.
- Each running session retains its process owner, lease, PID, session ID, and transport connection generation. Stop waits
for both process-exit confirmation and the matching disconnect generation; a missing disconnect replaces the transport
before later starts.
- An unconfirmed process exit is retained as an unconfirmed lease. New start/restart intents fail until ownership can be
cleaned up, preventing two Core instances from being treated as the active session. Terminal close may continue on a
best-effort basis because the application is exiting.
- An unexpected disconnect or transport failure while running is converted to `DesktopCoreFailure`, the owned process is
cleaned up, and `CoreService` emits a Core crash event for the normal UI recovery path.
Direct launch is used on macOS, inside an AppImage, and as the Windows/Linux fallback when the privileged Helper is not
ready. When the Helper is ready, it owns the Core child and Dart owns it through a session-scoped lease.
### Android Service Lifecycle
Android deliberately keeps Flutter requests optimistic and the native layer authoritative:
- `ServicePlugin.start()` and `stop()` acknowledge immediately after submitting intent. They do not wait for service
creation, VPN permission, binding, TUN establishment, or teardown.
- `ServiceState` owns the latest `RunRequest`, shared configuration, run time, and `STOPPED`/`STARTING`/`STARTED`/`STOPPING`
state. Identity checks discard obsolete work. `startPreparationLock` serializes permission/setup preparation and
`transitionLock` serializes actual service transitions.
- `ServiceController` owns exactly one `ManagedServiceBinding`, selects `VpnService` or `ProxyService` from `VpnOptions`,
binds with a five-second connection timeout, invokes `ManagedService.start()`/`stop()` off the main thread, and clears
binding/run-time state on failure or disconnection.
- Generic service creation/destruction is lifecycle evidence, not user intent. New commands must flow through
`ServiceState.requestStart()`/`requestStop()` or the explicit system-action handlers instead of inferring intent from a
callback.
Quick Settings, notification, revoke, and Always-on VPN paths converge on the same owner:
- With a Flutter engine attached, `ServiceState.handleStartAction()`/`handleStopAction()` forward through `TilePlugin` to
`TileManager`, which updates normal Flutter setup state. Without Flutter, native code restores `SharedState` from
preferences, runs `quickSetup`, checks VPN permission, and submits the native request directly.
- Android may create an Always-on `VpnService` through `onStartCommand()` without FlClash's bound-service path. The service
sends the explicit, permission-protected `VPN_START_REQUESTED` broadcast to `ServiceBroadcastReceiver`, which routes it to
`ServiceState.handleStartAction()` so Core/configuration and the normal binding are restored before TUN is treated as
ready.
- `VpnService.onRevoke()` stops TUN/modules first, then sends `VPN_REVOKED`; the receiver only requests a stop when
`ServiceController` still owns an active VPN binding.
- `ServiceBroadcastReceiver` uses `goAsync()` and an atomic one-shot completion. Normal completion or a nine-second
watchdog calls `PendingResult.finish()` exactly once; the watchdog releases Android's broadcast lease and does not
cancel or redefine the underlying lifecycle intent.
## Core Protocol And Event Delivery
The shared protocol uses `CoreMethodCall(id, method, arguments)` and `CoreMethodResponse(id, result, error)` in both
directions. The envelope is the only JSON serialization layer: keep arguments, results, and event data as structured JSON
values rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents,
remain strings.
Go event delivery is intentionally non-blocking:
- Loaded-provider, geo-update, and route-changed events use a state queue that never evicts; the rules for it are under
Core API Safety in `.agents/rules.md`. Every other event except the bulk kinds, delay included, uses a 256-entry
priority queue. Desktop process crashes are generated locally by `CoreService` from lifecycle failures rather than
sent through the Go queue.
- High-volume log, request/connection, and DNS query events use a separate 256-entry bulk queue, so bulk floods cannot
evict state.
- A full priority or bulk queue evicts only its own oldest event and retries the newest; DNS events never evict. Core
work never blocks on event delivery.
- The batcher flushes at 32 messages or every 16 milliseconds. Priority events are preferred, but one bulk opportunity is
guaranteed after eight priority messages to prevent starvation.
- `sendMessage` drops an event before it is queued when `hasEventListener` is false. That only happens on Android, after
the Flutter engine detaches while the service keeps the Core running; request and DNS events would otherwise be
encoded per connection and per query for nobody.
DNS query events come from the Clash.Meta fork's `dns/patch.go` and are raised where mihomo writes its `[DNS]` debug
log, so the DNS page lists the same resolutions: one event per cache hit, and one per upstream exchange however many
callers singleflight merged into it, carrying the upstream whose answer was used. Fake-IP and hosts answers never reach
a resolver and are not recorded. Queries from apps arrive with the `DNSContext` that `Service.ServeMsg` created and
report `app`; other initiators come from `resolver.WithInitiator` at the call site (rule matching, DIRECT, proxy
server dialing), and unlabeled queries report `other`.
Desktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate
listener exceptions so one faulty observer does not prevent the remaining events/listeners from running.
## User-Facing Core And Delay Feedback
`CoreStatusButton` in `lib/views/dashboard/widgets/core_status_button.dart` is the desktop dashboard's status/restart
surface. It is shown only outside dashboard edit mode and only when `coreLib == null`:
- Provider state remains authoritative. The widget keeps a separate display-only status so a fast
`connecting -> connected` transition still shows at least 600 milliseconds of progress instead of flashing.
- The hold arms only after an observed transition to `connecting`; mounting while already connecting does not invent a new
delay. A real `disconnected` transition cancels the hold immediately so failure is never hidden, while a long-running
connecting state remains visible after the timer expires.
- Taps during the display hold or while the provider is genuinely connecting are inert. Connected/disconnected taps show
the appropriate confirmation and delegate restart to `CoreAction`; the widget never starts Core directly.
Proxy delay testing follows the same failure-safe UI rule. `ProxiesAction._runDelayTests` marks its keys in
`pendingDelayTestsProvider` and releases them in `finally`, and a test the Core does not answer leaves the last
measurement in place; the delay-test bullets under Core API Safety in `.agents/rules.md` give the reasons. `ProxiesView`
clears the loading flag of its delay test action in `finally`, so an RPC failure cannot leave the action spinning.
## Route Consistency
A probe result on the dashboard — the exit IP, a node's exit IP, a service check — either describes the route in
force or is being asked again. The full design is `docs/plans/probe-route-consistency.md`; the mechanism has three
parts.
- The Core is the source of truth for the route. `core/route.go` keeps a structural epoch (advanced by a config
apply, a mode or IPv6 switch, and any proxy provider whose version changed) and a `picks` map from each Selector,
URLTest and Fallback group to what it resolves to right now, versioned on every change. `handleChangeProxy`
re-reads the picks under `selectMu` and answers `ChangeProxyResult{changed}`, so the connection reset only runs when
the pick moved. A health check moving a URLTest pick has no mihomo hook, so the picks are polled every second while
the host watches (`watchRoute(true)`) and the listeners are up. Every probe answer carries the counters it started
under; `routeChanged` publishes the state whenever it differs from the last read.
- `routeTrackerProvider` in `lib/providers/route_state.dart` merges that with the host's side: `proxied` (running and
not suspended), a host epoch that a connectivity event moves, and `synced`, which is false until `watchRoute` has
returned a snapshot and again after a Core disconnect. Nothing is fresh while unsynced, so a cache never judges
answers against counters it made up. The tracker subscribes when the first target is watched and releases the Core
when the last one goes; an answer newer than the state means an event was evicted, and the cache asks for a resync.
It also releases the Core while the app is hidden and reads it again on return, keeping `synced`: the Core and its
counters are the same ones, so the fresh snapshot invalidates only what moved. On Android the Core drops the watch
itself when the event listener is cleared, because an engine that goes away never sends `watchRoute(false)`.
- `RoutedProbe` in `lib/providers/routed_probe.dart` is the one cache both `OutboundIpProbe` and `ServiceStatus` mix
in. A widget `watch`es the target it shows and `unwatch`es on the way out; only watched targets are probed, one
request in flight per target. An answer is stamped with the Core's counters and the host state at launch, and
`RouteState.isFresh` walks its chain against the picks, so a selection change only invalidates results that went
through the changed group while an epoch change invalidates everything. A stale watched entry is asked again at
once; an unwatched one is dropped. Nothing launches while the app is hidden: what falls due waits for it to be shown.
Failures wait for `retry`, `refresh`, or the app resuming (`markResumed`), and
a service check that timed out is a failure that still shows its status. `refresh` is the user's own request and
runs for unwatched targets too: the service sheet reads the cache and never watches its rows, because one service
check is one to four full HTTPS fetches where a delay test is a single empty request.
`ipQuality(ip)` in `lib/providers/ip_quality.dart` is keyed by the outbound IP found, so a route change reaches it as
a new IP and an unchanged IP reuses the result. A lookup races its sources the way the IP check does and takes the
first one that determines a type. A success is kept for the session with `ref.keepAlive()`; a failure is not cached,
and Riverpod's automatic retry is off because a retry after an HTTP 429 spends the same per-outbound-IP quota again.
## Icons
`lib/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and
`test/lint/material_icons_test.dart` fails on any `Icons.*` or `AnimatedIcons.*`, so every icon shares one stroke and
can animate its shape.
A `Glyph` is a list of shapes on a 24-unit grid, stroked at `Glyph.strokeWidth` with round caps and joins and
superellipse corners; a curve the primitive shapes cannot draw is a `GlyphPath`, SVG path data traced along the
stroke's center. Each shape has a role that decides its filled form, the way SF Symbols and Fluent pair an
outlined icon with its filled variant:
- `body` fills;
- `detail` is cut out of the fill, and a `solid` detail such as a dot is cut as an area;
- `line` stays a stroke, for parts that belong to no body, like a handle or a link between nodes; it thickens from
`Glyph.strokeWidth` toward 2.2 as the glyph fills, so a glyph of lines alone still carries weight in a filled button.
`GlyphPainter` paints any point between the two forms. Each body fills inward from its outline, reaching half its
shorter side, which no point of the body lies beyond, as `fill` reaches 1. Details thin away over the first half and
return as cutouts over the second, so a detail is never a stroke and a cutout at once. The glyph is painted opaque into
one layer that carries the color's alpha: a translucent color, such as a disabled one, then stays even where strokes
cross the fill, and a cutout clears only the glyph's own pixels. `GlyphIcon` shows a glyph at a fixed `fill`, taking
its size, color, and fill from `IconTheme` unless it is given its own, as `Icon` does, and mirroring it in right-to-left
text when `Glyph.matchTextDirection` is set.
`AnimatedGlyph` drives a `GlyphIcon` from its `filled` flag and pops briefly when it fills. A glyph whose shape follows
some other state, like the sidebar toggle that tracks the pane's width, is a builder in `AppGlyphs` such as
`AppGlyphs.sidebar(progress)`. A new glyph goes in `AppGlyphs` and in the list `test/icons/glyph_painter_test.dart` runs
through its checks; it is named for what it means in the app, one glyph per meaning, with SF Symbols and Fluent as the
reference for the metaphor. Tests find one with `find.byGlyph` from `test/helpers/glyph_finders.dart`.
Icons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, `FilledButton`
in its tonal and icon forms, and floating action buttons draw their glyph with `fill: 1`, because an outline on a tinted
container reads weaker than the container it sits in. A glyph with no body only thickens its lines and still takes
`fill: 1`, so the rule holds if the glyph later gains one. `test/lint/filled_button_glyphs_test.dart` checks every
`GlyphIcon` written inside such a button; an icon that reaches the button from elsewhere, as through a helper that picks
the button variant, gets its fill by hand or from `IconThemeData.fill`. Everywhere else fill marks state only: the
selected destination, a pinned window, a lit torch. The phone's floating navigation bar is the one exception: like an iOS
tab bar it fills every destination and marks the selected one by colour alone.
Framework widgets that draw their own icon take it from the theme: the `ActionIconThemeData` in `lib/application.dart`
supplies the back and close buttons, with `AppGlyphs.backFor` picking the platform's back glyph, and a `DropdownMenu`
is given its chevrons. A framework widget added later that paints a Material icon needs the same treatment.
Empty states draw no image assets either. `NullStatus` in `lib/widgets/null_status.dart` shows the glyph for its
meaning filled on a Material 3 Expressive shape from `material_new_shapes`, with a few accent marks, and enters with
one of three choreographies picked at random: the shape spins in, morphs out of a circle, or springs in while the
accents orbit into place. The glyph takes `onPrimaryContainer`, not `primary`: the `content` scheme variant can leave
`primaryContainer` as light as `primary` in dark mode.
`CaptionIcon` is the one icon that does not follow the glyph grid: the window caption buttons keep the one pixel,
pixel-aligned stroke of Windows' 10-pixel caption glyphs on every platform.
## App Bar Actions
`CommonScaffold` keeps at most two buttons in sight in its bar. A page hands it what may fold as data: `primaryAction`,
`iconActions`, `menuItems`, and `searchActions` while searching; custom widgets passed through `actions` never fold.
Past two, only the search button (the clear button while searching) stays in sight, or on a bar without search the
primary action, or the first icon action when `foldPrimaryAction` is set; everything else goes into one overflow menu,
primary action first, and a folded action that is loading shows as a disabled item. `primaryAction` counts only where the bar holds it, in a bottom sheet and on a phone's home
page, whose foot the navigation dock takes; elsewhere it is the page's floating action button. No page adds a
floating action button of its own: a secondary action such as selecting every app goes into `menuItems`, and saving
an edit page is a check among its `iconActions`. Actions on a multi-selection, such as delete and select all, go into
`selectionActions` instead: the bar keeps them in sight as one button group that takes a single slot beside the search
or clear button, so they never fold.
## Settings Rows
`lib/widgets/config_item.dart` holds the shared settings-row vocabulary: `ConfigToggleItem`, `ConfigOptionsItem`,
`ConfigTextItem`, and `ConfigListEditItem`. Each takes a `selector` (a `ProviderListenable`, normally
`someProvider.select(...)`) and an `onChanged(ref, value)` writer, and watches its own selector so changing one setting
rebuilds one row instead of the whole section. Titles and subtitles are `ConfigLabel` callbacks that receive
`AppLocalizations`, which keeps literal labels such as `IPv6` and localized labels in the same shape.
Build settings screens from these directly, or from a file-local helper that binds one provider once — see `_networkToggle`
in `lib/views/config/network.dart` and `_appSettingToggle` in `lib/views/config/general.dart`. Declare a named
`ConsumerWidget` only when a row is genuinely reused across screens, as `lib/views/config/network.dart` rows are by
`lib/views/dashboard/widgets/quick_options.dart`. Rows with bespoke behaviour — a custom dialog, a derived value, or a
second provider write — stay hand-written rather than growing extra parameters on the shared items.
## State Management
Provider files in `lib/providers/`:
- `app.dart`: runtime/UI state, logs, traffic, delays, loading, navigation.
- `config.dart`: persistent config providers, app settings, theme, VPN, proxy style.
- `state.dart`: derived/computed providers, navigation, proxy, tray, color scheme.
Like `action.dart`, this is an entry point only: the providers live under
`lib/providers/state/` and are joined with `part` directives, so importing
`state.dart` still reaches all of them.
- `state/proxies.dart`: group and proxy lists, filter/sort, delay, selection.
- `state/navigation.dart`: navigation items, current page, dashboard, more tools.
- `state/system.dart`: tray, VPN params, access control, hot keys, shared state.
- `state/theme.dart`: dynamic color, color scheme, brightness.
- `state/profile.dart`: profiles, current profile, clash config, setup state.
- `state/overwrite.dart`: custom overwrite validity and the staged group/rule notifiers.
- `action.dart`: business logic notifiers, setup, backup, core lifecycle, proxy selection.
- `core.dart`: `coreHandlerProvider`, the container-scoped handle on `CoreController`.
- `route_state.dart` and `routed_probe.dart`: the route tracker and the probe cache; see Route Consistency above.
- `outbound_ip.dart` and `service_status.dart`: the dashboard probes built on them.
- `database.dart`: Drift database provider wrappers.
### Reaching Singletons
`lib/common/` and `lib/core/` publish process-wide singletons (`coreController`,
`system`, `preferences`, `appPath`, `request`, and others). Code that already has a
`Ref` or a `WidgetRef` reads them through a provider instead, so a test can scope a
fake to one `ProviderContainer` rather than swapping a global and relying on a
tearDown to put it back.
`coreHandlerProvider` is the established case. Every call site under
`lib/providers/`, `lib/manager/` and `lib/views/` goes through it; notifiers and
`ConsumerState` classes that touch Core repeatedly hold it as
`CoreController get _core => ref.read(coreHandlerProvider)`.
Tests override it with `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))`,
which does not claim the singleton. `CoreController.test` does claim it, and is
only for tests that have not moved yet. Prefer the scoped override even when a
test passes either way: a test that claims the singleton makes the global and the
provider resolve to the same fake, so it cannot tell a provider read from a
leftover global read, and a half-migrated call site stays green.
One deliberate exception: `globalState` owns the `ProviderContainer`, so it cannot
itself live in one. Code without a `Ref` reaches providers through
`globalState.container.read(...)`. The remaining call sites are `lib/core/lib.dart`,
`lib/common/print.dart`, and the tray `read` callback in
`lib/providers/actions/system.dart` — all singletons or platform callbacks with no
`Ref` in scope.
`lib/models/profile.dart` does not reach Core. `Profile.saveFile` and
`Profile.update` take a `ValidateConfig` callback, and every caller passes
`(path) => _core.validateConfig(path)`, keeping the Core handle lazy so a profile
path that never validates never resolves the controller.
The UI layer must not reach a process-wide singleton directly.
`test/lint/ui_layer_singleton_test.dart` scans `lib/views`, `lib/widgets`,
`lib/pages` and `lib/features` for `globalState.container` and the bare
`coreController` global and fails the run on either. Widgets that need Core hold
`CoreController get _core => ref.read(coreHandlerProvider)`.
`globalState.measure` and `globalState.theme` stay global on purpose. Both are
context-derived caches assigned by `ThemeManager`, and tests already scope them by
assigning in the app builder (see `test/helpers/test_app.dart`); moving them into
providers would touch every layout call site without changing behaviour.
`globalState` in `lib/state.dart` is a singleton holding ambient app state — the
package info, the measure and theme, the container, and the start/stop flags —
plus `safeRun`/`loadingRun`. Startup orchestration is **not** on it: `init` and
`attach` live in `lib/bootstrap.dart`, above `lib/common`, because they drive the
window, the autostart entry, the tray and the permission prompts. Providers are
generated into `lib/providers/generated/`.
The root navigator key lives in `lib/common/navigator.dart` as `rootNavigatorKey`;
`globalState.navigatorKey` is a getter onto it. `lib/common/dialog.dart` reaches
the key directly and does not import `lib/state.dart`.
### Platform Layering
`lib/common/common.dart` deliberately does not export `tray.dart`, `window.dart`,
`launch.dart`, `system_dns.dart`, or `permission.dart`. Those five modules import
`tray`, `window`, `launch_at_startup`, and `screen_retriever`;
exporting them would put those packages in the compile graph of every file that
imports the barrel for a string helper. Import the specific module instead.
`test/lint/platform_layering_test.dart` enforces four rules. Three are local: the
barrel never re-exports one of those five modules, nothing under `lib/common`,
`lib/enum` or `lib/models` other than those five imports a desktop platform
package, and `lib/common` never imports the `lib/manager/manager.dart` barrel
(import the single manager needed, as `common/context.dart` does with
`manager/status_manager.dart`). The fourth walks the barrel's whole transitive
closure and fails if *any* file in it imports one of those packages. That one is
the real invariant — the local rules only stop the shortest path, and every leak
found so far arrived through a longer one.
The shapes that keep the closure small:
- `System.back` and `System.exit` do not touch `window`; the window half of
both lives in `SystemAction`.
- `KeyboardModifier.toHotKeyModifier()` lives in
`lib/manager/hotkey_manager.dart`, its only consumer, not in `lib/enum/enum.dart`.
- Startup orchestration lives in `lib/bootstrap.dart`, not on `GlobalState`.
`common/num.dart`, `common/print.dart` and `common/request.dart` import
`state.dart` for `theme`, `container` and `packageInfo`/`ua`, so anything
`GlobalState` reaches lands in the barrel's closure; the ambient state it
holds reaches nothing platform-specific.
- `SystemAction` talks to `WindowPort` and `TrayPort` from
`lib/common/app_ports.dart` instead of importing `common/window.dart` and
`common/tray.dart`. `lib/bootstrap.dart` binds `windowPort` and `trayPort` to
the real implementations; both stay null in tests, where every call through
them is a no-op. A test that needs the real tray assigns `trayPort` itself, as
`test/common/tray_menu_test.dart` does.
- `lib/providers/providers.dart` re-exports `action.dart`, so `lib/common` and
`providers/app.dart` import `providers/state.dart` and `providers/config.dart`
directly; the barrel would reach the whole action layer.
- A data type in a lower layer never holds the widget that renders it, because
that drags the whole view tree into the barrel's closure. The route table is
`lib/views/navigation.dart` implementing `NavigationPort`, which
`providers/state/navigation.dart` reads through and `bootstrap.init` binds;
unbound it yields no items, so a test that renders navigation assigns
`navigationPort` itself, as `test/pages/home_test.dart` does. `DashboardWidget`
is plain data, since it is persisted in the app settings; the widget mapping
lives in `lib/views/dashboard/widget_registry.dart`, the reverse lookup relying
on the branches returning canonical consts.
`test/lint/platform_layering_test.dart` also pins that the barrel's closure
contains no `lib/views` file. Data the provider layer needs from the UI layer
goes through a port in `lib/common/app_ports.dart` rather than an import in the
other direction.
### High-Frequency Buffers
`logsProvider`, `requestsProvider`, `dnsQueriesProvider` and `trafficsProvider` hold a `FixedList`
(`lib/common/fixed.dart`), which trades a normal copy-on-write for a shared
buffer tagged with a generation counter:
- `append` mutates the buffer in place and returns a new wrapper one generation
ahead. That is what providers publish, so `updateShouldNotify` still fires.
- `list` returns an immutable copy, cached until the next mutation. It must stay
eager: an older wrapper shares the buffer, so its contents move on. Anything
that needs a stable view has to read `list` at the moment it is notified, not
hold the wrapper and read later.
- Consumers that only need to know *that* the buffer changed watch `revision`,
not `list` — selecting on the list snapshots and deep-compares the whole
buffer on every arrival, which is what this design exists to avoid. See
`lib/views/logs.dart` for the pattern: watch the generation, snapshot inside
the throttled callback.
`add`/`clear` mutate in place without advancing the generation; use them only on
a buffer you own outright (seeding, resets, tests), never on published state.
### Background Work
A proxy client runs for days with nobody looking at it, so work that only feeds the UI stops when the UI cannot be
seen. There are three levels of gating:
- `appVisibleProvider` is false while the desktop window is hidden or the Android activity is in the background
(`hidden`, `paused`, `detached`). `AppStateManager` writes it. The one-second run-time and traffic ticker in
`SetupAction` follows it. The macOS menu bar title is the exception: it shows the traffic while the window is
hidden. `RouteTracker` and `RoutedProbe` follow it too, as described under Route Consistency.
- `ActivePollingMixin` is stricter for per-widget polls such as connections and memory. It polls only while the app is
`resumed` and its home page is the current one.
- `_NavigationPage` puts every page except the current one under a disabled `TickerMode`. Home pages are kept alive in
the `PageView`, and Flutter does not mute tickers on a kept-alive page off screen. A spinner left on one would
otherwise keep scheduling frames.
A provider's own `ref.listen` delivers nothing while no one listens to that provider. That is the usual state of an
action notifier, which is only ever read. A derived provider it listens to is never recomputed, and even a `ref.read`
of it returns a stale value. So an action that has to follow a signal listens to the keep-alive provider that holds
it, as `SetupAction` does with `appVisibleProvider`, not to a provider derived from that.
On Android, `SuspendModule` suspends the Core while the screen is off and the device is in Doze. Health checks that
run in Doze all fail, so after a suspension the Core re-probes every provider. That re-probe waits for the screen to
come on. A Doze maintenance window resumes the Core too, but probing there would spend the window's radio time on
results nobody sees.
## Database
The app uses Drift/SQLite in `lib/database/`. `schemaVersion` in `lib/database/database.dart` is the migration
counter; every schema change bumps it and adds the matching migration step.
Tables:
- `Profiles`
- `Scripts`
- `Rules`
- `ProfileRuleLinks` (`profile_rule_mapping`)
- `ProxyGroups`
- `CustomProxies` (`custom_proxies`)
- `IconRecords` (`icon_records`)
- `ClashProviders` (`clash_providers`)
Rule scenes distinguish global added rules, profile added rules, profile custom rules, and disabled links. Rule and proxy-group ordering use fractional indexing.
App-level providers (`ClashProviders`, plus every profile offered as a proxy provider) and a custom overwrite's own
proxies (`CustomProxies`) are soft-disabled through `feature.customProviders` and `feature.customProxies`
(`lib/common/feature.dart`), switched on by `--dart-define=FEATURE_CUSTOM_PROVIDERS=true` and
`FEATURE_CUSTOM_PROXIES=true`. While off, their entries are hidden, `appProviderNames` and `appProviderLabels` are
empty, and `setupState` loads neither, so stored rows stay in the database but never reach a profile. Backups still
carry both tables.
Generated Drift output lives in `lib/database/generated/database.g.dart`. After schema changes, run code generation and add or update focused database tests under `test/database/` when converter or migration behavior changes.
## Manager Stack
Managers are nested `InheritedWidget`/`StatefulWidget` components built by `buildManagerStack()` in `lib/application.dart`:
```text
AppEnvManager > LocaleManager > StatusManager > ThemeManager
> [Desktop: WindowManager > TrayManager > HotKeyManager > ProxyManager]
[Mobile: AndroidManager > TileManager]
> AppStateManager > CoreManager > ConnectivityManager
> [Desktop: WindowHeaderContainer] [Mobile: VpnManager]
> app content
```
Each manager in `lib/manager/` handles a specific platform concern. The
platform slots are exclusive: no desktop manager appears on mobile and no mobile
manager appears on desktop.
The order is an ownership contract, not a layout detail. `ConnectivityManager`
sits below `CoreManager` because its `onConnectivityChanged` callback reads
Core-backed state, so Core must already be mounted when it fires. `StatusManager`
and `ThemeManager` sit above the platform managers so a platform manager can
surface a message or read the theme.
`buildManagerStack()` is a pure function of `isDesktop`, the connectivity
callback, and the app content, so `test/application_test.dart` asserts the whole
order by constructing the stack without mounting it. Changing the nesting means
updating both this diagram and that test.
## Core Controller and Actions
`lib/core/controller.dart` (`CoreController`) is a singleton facade over `CoreHandlerInterface`. Public methods delegate to the platform-specific interface, either the Android service channel (`CoreLib`) or the desktop socket. It has an `@visibleForTesting` constructor and `resetInstance()` for test injection.
`lib/providers/action.dart` is the public library entry point for action
providers. The Riverpod notifier implementations are split by responsibility
under `lib/providers/actions/` and joined to the entry point with `part`
directives, so consumers continue to import the same public API:
- `CommonAction`: update check and common UI operations.
- `SetupAction`: config setup and TUN management.
- `BackupAction`: backup/restore orchestration over a per-run temp work directory; the view supplies where an archive
goes (local save, WebDAV upload) or comes from. The archive format lives in `lib/common/backup.dart`, which stages a
restore without touching the data directory; files, database rows and settings are applied only after the whole
archive has been read and validated.
- `CoreAction`: core lifecycle, initialization, coalesced restart, and post-restart profile/running-state application.
- `SystemAction`: system integration, tray, coordinated resource cleanup, terminal Core close, exit, and brightness.
- `StoreAction`: profile storage operations.
- `ThemeAction`: theme state updates.
- `ProxiesAction`: group management and proxy selection.
- `ProfilesAction`: profile CRUD, auto-update, import.
- `GeoResourceAction`: geo resource updates and URL configuration.
- `UpdatingAction`: stale sweep over `UpdatingKeys`.
`UpdatingKeys` in `lib/providers/app.dart` owns every per-entity updating flag; `isUpdatingProvider(key)` is the
read-only view widgets watch. Callers pair `start(key, scope: ...)` with `stop(key, operation)` using the returned
token, so overlapping operations on one key are reference counted and a late `stop` from a superseded operation
cannot clear a newer one. Keys started with `UpdatingScope.core` depend on the Core to make progress, so
`UpdatingKeys` discards them itself when `coreStatusProvider` leaves `connected` — that is the state's own
invariant, not a policy, and it must stay inside the notifier where no warm-up ordering can miss it.
`UpdatingScope.local` keys (profile updates run entirely in Dart) survive a Core restart. `UpdatingAction` holds
only the policy half: the periodic sweep that discards a key stuck past `updatingStaleTimeout`. Do not move the
timeout back into `UpdatingKeys`, and do not widen the disconnect reset to every scope.
## Safe Mode
`--dart-define=SAFE_MODE=true` builds a FlClash that never changes the host network, so a development build can run
next to an installed one. It is a compile-time constant with two faces: `safeModeBuild` in `lib/common/constant.dart`
for the process-wide handles that exist before the provider container, and `safeModeProvider` in
`lib/providers/app.dart` for everything reached through Riverpod, which is also what tests override. Settings are never
rewritten; each owner reads the flag where it would otherwise act:
- Core config: `SetupAction._effectivePatchConfig` is the one place the profile path and `updateConfig` derive what
the Core receives from the user's `PatchClashConfig`. In safe mode it reports `tun.enable: false` and
`ExternalControllerStatus.close`, so the generated profile and every `updateConfig` carry no TUN and an empty
`external-controller` (the Core's `route.ReCreateServer` binds no RESTful API port) while the settings keep the
user's values and the installed app keeps `127.0.0.1:9090`. `SetupAction.requestAdmin` skips authorization.
- System clock: `_makeRealProfileTask` sets `write-to-system: false` on whatever `ntp` section the generated profile
ends up with, so neither the user's override nor the profile's own NTP config can move the host's time.
- System proxy: the `proxy` plugin handle in `lib/common/proxy.dart` is `null`, so `ProxyManager` and exit cleanup
never write the OS setting, not even the `stopProxy` that would clear what the installed app configured.
- System DNS: `systemDnsCoordinator` is `null` and `shouldPatchSystemDnsProvider` is `false`, so neither the apply nor
the restore path runs. The restore path matters because a profile or release build shares the preferences of the
installed app (only a macOS or Android debug build has its own bundle id) and would restore from its record.
- Login item: the `autoLaunch` handle in `lib/common/launch.dart` is `null`, so neither the boot sync nor the setting
listener registers the build's executable or removes the installed app's entry, which on Windows is the same
`FlClash` value in the user's Run key.
- Privileged Core: `CoreService` resolves the launcher with `hasHelper: false`, which is the direct child-process path
on every desktop platform; the Helper keeps managing the installed app's Core.
- Core listeners: `SetupAction._setCoreRunning` and the `AppStateManager` suspend listener never call `startListener` or
`stopListener`, so the Core's `isRunning` stays false and `updateListeners` binds nothing, while `isStartProvider`
and `applyProfile` behave as usual. The installed app keeps the mixed, DNS and other loopback ports.
- Android: `sharedStateProvider` hands the service `VpnOptions.enable: false` and `systemProxy: false`, so the
foreground service runs without a `VpnService`.
- Wi-Fi SSID: `ConnectivityManager` never reads the SSID and `Permissions.checkLocationPermissions` neither checks
nor requests location authorization, so `currentSSIDProvider` stays `null` and the excluded-network suspend never
trips. The macOS `wifi_ssid` plugin builds its `CLLocationManager` and `CWWiFiClient` lazily, so a build that
never asks never reaches locationd or wifid.
- Global hotkeys: `HotKeyManager` neither subscribes to hotkey events nor registers the configured shortcuts with the
OS; the in-window `Shortcuts` (close window, escape) stay.
- URL schemes and launcher shortcuts: `Window.init` skips the Windows registry and Linux desktop-entry registration
of the `clash` schemes, and Android never calls `initShortcuts`.
- Presentation: `AppEnvManager` shows a `SAFE MODE` banner ahead of the channel banner, and `TrayState.safeMode` gives
the tray the `status_4` bug icon, a suffixed tooltip and a menu of only show and exit. `trayStateProvider` and
`trayTitleStateProvider` report `showTrayTitle: false`, so the macOS menu bar carries no traffic title.
A new host integration, anything that changes the OS's routes, proxy, DNS, login items, privileges or VPN state, or
that asks the OS for a permission, a global shortcut or a registration, must consult the flag at its owner in the same way and get a test that overrides `safeModeProvider`.
## Build System
`setup.dart` is the release build orchestrator: it writes `env.json` (`APP_ENV`), activates `flutter_distributor` from
the `chen08209/flutter_distributor` fork pinned to a `v<version>-flclash.<n>` tag (cut a new tag there and bump
`--git-ref` when the fork changes), and leaves the Core artifacts to the build hook.
The Go core and the Rust helper are built by a Dart build hook. `plugins/setup/hook/build.dart` only constructs
`CoreBuilder`, a `package:hooks` `Builder` in `plugins/setup/setup_hooks/`, the same shape `rust_api` uses. Flutter
runs it for every platform build and for `flutter test`, once per target architecture. `CoreBuilder` turns the hook
input into a `BuildRequest` (repository root, `Target`, Android toolchain) and hands it to `buildPlatform`. It lives in
`setup_hooks` so `dart test` there can cover it; a test run of `plugins/setup` itself would execute the hook. Failures
reach the runner as `BuildError` (Go or Cargo failed, no Core for the architecture) or `InfraError` (a toolchain could
not start, Flutter passed no NDK compiler, the package is not at `plugins/setup`).
What the hook protocol forces:
- Android derives the per-ABI clang wrapper from the C compiler and `targetNdkApi` (the app's `minSdk`) Flutter
passes; `hooks_runner` filters the environment, so `ANDROID_NDK` is not read.
- `PATH` arrives unextended, so `runCommand` appends the Homebrew, Go and rustup locations that Xcode's and Gradle's
stripped `PATH` hides.
- The hook does not know the build mode: the protocol carries none and `linkingEnabled` only says whether link hooks
run, so nothing in the hook branches on debug versus release. It cannot tell `flutter test` from a build either; the
`build_assets: false` user-define in the root `pubspec.yaml` is the one switch (see `.agents/commands.md`), and
`setup.dart` refuses to package while it is set.
- The hook declares the files it read but the *directories* it wrote: `hooks_runner` hashes a file by content and a
directory by child names, so `libclash/macos/` catches a deleted artifact without MD5-ing a 55 MB Core on every
build. A rebuild touches those directories after the runner's cutoff, so the next build runs the hook once more and
setup's cache answers it; that extra run is expected.
- The runner discards hook output on success, even under `-v`, so the hook mirrors it into
`.dart_tool/setup_build_cache/hook.log` (rotated at 512 KB), one `===` header per invocation.
Platform projects copy the artifacts out of `libclash/`; application code must not import `plugins/setup`:
- Android: the Go core is built `c-shared`, and `libclash.so` with its headers lands in the `:core` module (see Android
Native Task Ordering).
- macOS: a standalone `FlClashCore`. `Release.xcconfig` pins release and profile `ARCHS` to the host because
flutter_tools otherwise builds a universal binary and every artifact ships one slice; the hook skips a non-host slice
for the same reason. The `Stage Core` phase copies the Core after the hook may have rewritten it and fails when it is
missing or lacks a slice for `ARCHS`, so a skipped hook cannot stage a stale Core silently.
- Linux and Windows: `FlClashCore`, the Rust `FlClashHelperService`, and a `manifest.json` holding `coreSha256`; the
Core builds first because the Helper embeds its hash. The CMake `install` rules copy them, and the Windows bundle
places `manifest.json` beside the executable. A Helper running from a Debug build keeps its exe open and the install
fails behind an opaque `MSB3073`, so `windows/CMakeLists.txt` stops it from an `install(CODE)` step for the `Debug`
configuration only, and only when the staged Helper differs from the installed one; that step is where the
configuration is known, and a Release install never stops a registered service. The step matches the process by
executable path rather than image name, so a Helper registered from an installed release keeps running.
Setup keeps its own cache under `.dart_tool/setup_build_cache/v1/` because it builds a Go core and, on Windows and
Linux, a Rust helper:
- Go fingerprints cover the target-specific `go list -deps` inputs in `core/` and `Clash.Meta`, module files, the
effective build configuration, `setup_hooks` sources, target flags, the Go toolchain and the Android clang version.
Helper fingerprints cover its Rust sources and manifests, Cargo/Rust toolchains and flags, and the expected Core
SHA256.
- A hit requires the fingerprint and every recorded output's path, size and modification state to match, and skips
compilation and copying.
- Records are written only after a successful build, under per-target locks. Compilation happens in a staging
directory and moves into place on success, so a failed build never destroys the previous artifacts.
- `flutter clean` removes `.dart_tool` and forces one full rebuild; deleting `.dart_tool/setup_build_cache/` does the
same without a clean.
Windows helper integrity/version check:
- `setup_hooks` constructs the Core first, calculates its SHA256, and always
builds the Rust Helper with release hardening and that expected hash.
- Flutter reads the Core SHA256 from the bundled `manifest.json` and sends it with `/ping`. Debug, Profile, and Release
builds use the same Helper protocol and may use TUN through the same flow.
- `/ping` is loopback-only and requires no request token. The Helper compares the requested SHA256 with its embedded value
and checks that the fixed `FlClashCore.exe` beside it exists; `/start` performs the actual Core SHA256 verification before
every launch. The response includes the running Helper path and protocol header; Dart checks both against the current
installation. The launcher selects the Helper only when `/ping` reports ready; any other readiness (missing manifest,
unavailable Helper, or a Helper built for a different Core) falls back to the direct Core without requesting elevation.
If `/start` reports a pre-spawn failure — `coreVerificationFailed` (the on-disk Core no longer matches the SHA the
Helper and manifest agree on) or `processLaunchFailed` (the Core process could not be spawned) — the launcher degrades
to the direct Core rather than failing the launch. `/start` releases the previously managed Core before it verifies,
so the Helper owns no Core when either code is reported and the direct retry cannot race a Helper-managed Core.
A mismatched Helper is reinstalled through the explicit TUN authorization flow, not at startup.
- TUN is not a required run condition. A direct Core runs unelevated and cannot bring up TUN, so any degrade to the
direct Core — an unready Helper at resolve time, or a pre-spawn `/start` failure — silently drops TUN and keeps the
Core running. Degrading is preferred over failing the launch: an unverified Core carries no privilege the direct
launch path did not already have. `manifestMissing` is the one readiness that is surfaced to the user, because it
means the installation itself is incomplete.
- Flutter creates a 128-bit lowercase-hex session ID and uses it as the random named-pipe suffix. `/start` receives only
that address and session ID, validates the fixed `FlClashCore_<session>` namespace, starts the fixed Core beside the
Helper, and returns the same session ID plus the spawned PID. Flutter verifies both the session and named-pipe peer PID.
- `/stop` requires the same session ID. A missing process returns `notRunning`; a different owner returns
`sessionMismatch` without terminating that process. Session IDs are ownership tokens for lifecycle safety, not a claim
that the loopback HTTP endpoints are authenticated.
- The Core must not outlive the Helper. On Linux the systemd unit's cgroup and `Restart=on-failure` cover that; on
Windows the Helper puts the Core in a Job Object with `KILL_ON_JOB_CLOSE`, so the kernel kills the Core when the Helper
process ends for any reason, and the `install` subcommand configures SCM failure actions (three restarts, 5 s apart,
reset after 24 h). Stopping a Core sends SIGTERM first on Linux, because only a shutdown removes the policy routes
sing-tun installed; the Core runs the same teardown itself when its IPC host disconnects or it receives a termination
signal. `HelperClient.stopTimeout` must stay above the Helper's graceful-plus-kill budget.
- Never take `MANAGED_CORE` or `LOGS` with `lock().unwrap()`. The Helper is a long-lived service running as SYSTEM, so a
single panic while a lock is held would poison it and turn every later request into another panic — the service stays
dead until Windows restarts it. `lock_surviving_poison` recovers the guard through `PoisonError::into_inner` instead.
`hub.rs` uses it at every lock site, tests included, and two tests in that file pin the behaviour.
Build configuration defaults live in `plugins/setup/setup_hooks/lib/src/options.dart` and can be overridden via the root
`build_config.yaml`.
Architecture detection is automatic. The `--description` flag passed to `flutter_distributor` adds arch suffixes to artifact names, such as `FlClash-0.8.93-macos-arm64.dmg`.
#### Android Native Task Ordering
`:core` consumes `libclash.so` and its headers as files the setup build hook writes into `android/core/src/main`, not
as an asset the hook hands back, so nothing in Gradle's model links the two. AGP's configure fingerprint tracks only
`CMakeLists.txt` and its own generated files, which makes a wrong ordering *sticky*: a configure that ran before the
hook stays cached and every later build reuses it. `CMakeLists.txt` therefore links `clash` unconditionally, and
`android/core/build.gradle.kts` fails before any native task runs when those files are absent — both push the failure
out of the configure step, where it would be cached, and into a place that re-evaluates every build. Three details
there are easy to get wrong:
- `defaultConfig.ndk.abiFilters` is derived from the `target-platform` property Flutter passes to Gradle, mapped
through the same ABI table as `Target.forPlatform('android')` in `setup_hooks`; keep the two tables equal. The hook
builds a Core only for the platforms of the current build, so a `:core` ABI outside that set has no `libclash.so` to
link, and without any filter AGP would configure the NDK's full default ABI set.
- Match task names by **prefix**. AGP puts the ABI in the names of the tasks that do the work
(`configureCMakeDebug[arm64-v8a]`). The bare `configureCMakeDebug`, `buildCMakeDebug`, and `externalNativeBuildDebug`
that an exact-name match catches are grouping tasks that never enter an app build's execution graph.
- Use `mustRunAfter`, not `dependsOn`. `:core` declares only `debug` and `release` variants, so a **profile** app
consumes `:core`'s `debug` variant; no `:core` variant name maps onto the right `:app` task, and `dependsOn` would
drag a second, wrong-mode Flutter build into the graph.
A standalone `./gradlew :core:assembleDebug` stays legal once the hook has run at least once; it only warns that the
artifacts under `src/main` are whatever the last hook run left behind, which is the one failure mode this guard cannot
detect. Verify a change here with `./gradlew ":core:buildCMakeDebug[arm64-v8a]" :app:compileFlutterBuildDebug
--dry-run`: the Flutter task must be listed first even though the CMake task was requested first.
#### Android Crash Symbols
Crashlytics NDK symbolicates a native crash only from libraries that still carry their symbols, and AGP strips every
`.so` it packages, so the Android build keeps symbols until `:app`'s own strip step and the APK does not grow:
- The hook drops `-s` and `-w` from `go_ldflags` for the `c-shared` Android targets only; the desktop Cores stay
stripped because nothing consumes their symbols.
- `:core` compiles with `-g`, does not `--strip-all`, and sets `packaging.jniLibs.keepDebugSymbols` so its own strip
task leaves `libclash.so` and `libcore.so` intact on the way into `:app`'s merged native libs, which is where the
Crashlytics plugin reads them.
- `:app` turns on `nativeSymbolUploadEnabled` and finalizes `assembleRelease` and `bundleRelease` with
`uploadCrashlyticsSymbolFileRelease` only when release signing is configured, the same condition that marks a real
release build; the checked-in `google-services.json` is a placeholder, so a local upload is noise at best, and CI
supplies both files together. The Crashlytics plugin wires the R8 mapping upload itself but leaves the native symbol
upload to the caller, so nothing else triggers it. `generateCrashlyticsSymbolFileRelease` runs `dump_syms` over the
Go Core for every ABI, which is the other reason it stays off for unsigned builds.
## Local Plugins
- `setup`: build-time harness for Go core artifacts and the Rust helper, driven by a Dart build hook; no runtime Dart API.
- `proxy`: system proxy configuration.
- `rust_api`: runtime Flutter Rust Bridge FFI package built through Native Assets. See below.
- `tray`: system tray for Linux, macOS and Windows. Written for FlClash; replaced the `tray_manager` fork.
- `window`: desktop window control for Linux, macOS and Windows. Written for FlClash; replaced the
`window_manager` fork. See below.
- `wifi_ssid`: Wi-Fi SSID detection.
## window Plugin
`plugins/window` exposes `desktopWindow` (`DesktopWindow`) on the `window` method channel; `plugins/window/README.md`
is the channel contract (methods, arguments, events, effects, runner hooks) and every platform implements exactly that
table. `lib/common/window.dart` and `lib/manager/window_manager.dart` are the only application callers.
- Each platform keeps one style record (title bar style and button visibility everywhere, rounded corners on
Windows, effect, tint and brightness on Windows and macOS) and reapplies it whole from a single `apply` routine, so DWM margins
on Windows and `isOpaque`/effect views on macOS have one writer. Add a visual setting to that record, never a second
code path.
- Method dispatch is a table on every platform, and every handler validates its arguments; a bad call answers with a
`bad_args` `PlatformException`, a capability the platform lacks with `unsupported`. Coordinates cross the channel in
logical pixels; Windows converts with `GetDpiForWindow`.
- Position and size changes surface as one `geometry-changed` event that each platform coalesces natively (150 ms
after the last `WM_WINDOWPOSCHANGED`, did-move/did-resize, or configure-event); `WindowManager` only debounces the
capture on top of it.
- Windows: with `titleBarStyle: hidden`, `WM_NCCALCSIZE` lets `DefWindowProc` compute the client rect and then puts
the top edge back (plus the frame height when maximized, so the taskbar stays uncovered; plus 1 px otherwise, which
Windows 11 needs to draw the top border and Windows 10 needs to avoid a white line). Fullscreen is emulated by the
plugin, which raises `enter-full-screen` and
`leave-full-screen` itself and mutes `WM_SIZE` while it is on. The find-running-window IPC
(`WindowPluginFindRunningWindow`/`WindowPluginActivateWindow`) registers a window message from the executable path
and whitelists it through UIPI so a non-elevated launcher reaches an elevated instance.
- Linux: GTK drops the placement of an unmapped window, so `hide` saves the geometry and the `map-event` handler
applies it, then corrects the placement for 150 ms while the window manager settles (it may place the window
after the map handler ran). `isPositionSupported` is X11-only; Wayland owns placement and re-shown windows land where the
compositor puts them. Linux supports no effect: GTK 3 has no desktop-neutral blur protocol, so `isEffectSupported`
is true only for `none` and the runner keeps the default visual.
- macOS: `WindowPlugin.instance` is reached from `AppDelegate` for `applicationShouldTerminate` (emits
`should-terminate`) and `applicationShouldHandleReopen` (emits `activate`); `MainFlutterWindow` calls
`hiddenWindowAtLaunch()` so the first `order` keeps the window hidden until Dart shows it.
- Window effects (`transparent`, `blur`, `acrylic`, `mica`) are capability-gated by `isEffectSupported`; the Flutter
content must paint a translucent background for them to show. `ThemeProps.sidebarBlur` (on by default) drives
them: `Window.setBlur` picks `blur` on macOS and `acrylic` (or `blur`) on Windows and caches only a successful
probe, so a platform that answered unsupported is asked again the next time blur is turned on. `WindowManager` reapplies it
whenever the setting or the app brightness changes and records the outcome in `windowBlurProvider`, and only the
sidebar and the caption strip paint translucently while it is on. On macOS the sidebar paints fully transparent
and the effect view follows the window's active state, which is the flutter_acrylic `TransparentMacOSSidebar`
look; Windows keeps a 50% tint because the accent effects blur too little for the rail to stay readable.
- Sidebar blur is soft-disabled: `feature.sidebarBlur` (`lib/common/feature.dart`) is off unless the build passes
`--dart-define=FEATURE_SIDEBAR_BLUR=true`. While it is off the theme page hides the toggle and
`windowBlurRequestProvider` asks for no effect whatever `ThemeProps.sidebarBlur` holds. `WindowManager` still sends
that request at startup: the macOS plugin installs its effect view in `ensureInitialized` and leaves it active until
the first `setEffect`, so the `none` request is what makes the window opaque and parks the effect view.
## rust_api Crate Layout
`plugins/rust_api` has no platform folders. `hook/build.dart` is a Dart build hook: Flutter runs it for every platform
build and for `flutter test`, and `flutter_rust_bridge_hooks` (over `native_toolchain_rust`) compiles the crate with
Cargo and registers `librust_api` as a code asset that Flutter bundles and signs. The build runs
`rustup run <channel>`, so `rust/rust-toolchain.toml` must pin an exact channel and list every target the project ships;
the hook refuses `stable` and a target missing from that list. `rustup show` installs the pinned toolchain and those
targets on first use.
`flutter_rust_bridge_codegen generate` (see `.agents/commands.md`) writes `lib/src/rust/frb_generated.dart`,
`frb_generated.io.dart`, `frb_generated.web.dart`, one `lib/src/rust/api/<module>.dart` per Rust API module, and
`rust/src/frb_generated.rs`; none of them is edited by hand. A new Rust function is a public function in `rust/src/api/`,
plus `pub mod <name>;` in `api/mod.rs` and an export from `lib/rust_api.dart` when it starts a new module. `RustLib` is
the singleton entry point: `RustLib.init()` runs before any call and `RustLib.dispose()` on teardown.
`plugins/rust_api/rust/src/` separates the bridge boundary from the code behind it:
- `api/` is the only input flutter_rust_bridge parses (`rust_input: crate::api`). Every function there is a thin
delegation, so the generated bindings stay identical on every platform.
- `ipc/` implements the desktop socket server: `frame` (length-prefixed framing and the write backoff), `queue` (the
bounded send queue), `platform` (socket cleanup, Windows peer credentials and the non-blocking pipe reader), and
`server` (lifecycle, accept loop, and the `RUNNING`/`STATE` globals).
- `script/` runs profile override scripts on QuickJS through `rquickjs`.
- `hotkey/` registers desktop global shortcuts through `global-hotkey`: `keys` maps Flutter USB HID usages to key
codes, `owner` runs every registration on the thread the platform binds it to (a dedicated message-loop thread on
Windows, the main dispatch queue on macOS, in place on Linux), and `service` owns the registry and forwards presses
to Dart. Linux is X11 only; a Wayland session without XWayland gets an error rather than a silent no-op.
What a platform does not use, it does not compile. `interprocess` and `global-hotkey` are declared under
`cfg(not(target_os = "android"))`, and `ipc/mod.rs` and `hotkey/mod.rs` swap in their `unsupported.rs` there, because
Android loads the Core in-process and has no global shortcuts. Adding a capability follows the same shape: implement it in its own module, gate the
dependency by target, and keep the `api/` entry point unconditional so one set of bindings still serves every platform.
`RustLib.init()` runs on every platform, because the script engine is shared.
## Profile Script Engine
`lib/common/javascript.dart` sends the profile as JSON to `evaluate_script`, which runs `main(config)` on QuickJS and
returns the JSON the script produced. Nothing about the script runs in Dart.
- QuickJS is compiled from source for the target being built, which is what removed the prebuilt `quickjs-c-bridge`
binaries: `flutter_js` shipped x64 Windows and desktop-only libraries, so Windows ARM64 could not start (#2361).
- `rquickjs` carries pre-generated bindings for every target this project builds except the Android ones, so Android
builds enable its `bindgen` feature. That needs the NDK's own libclang and sysroot: `native_toolchain_rust` exports
the sysroot through `BINDGEN_EXTRA_CLANG_ARGS_<target>`, and `hook/build.dart` adds `LIBCLANG_PATH` from the NDK
toolchain Flutter hands the hook, because bindgen otherwise loads whatever libclang the host has, or none.
- Evaluation is bounded: a 10-second interrupt deadline and a memory ceiling, because a script that never returns would
otherwise hold the profile forever. `console` is installed before the script runs, since scripts written for other
clients log as they work.
- `rust/tests/fixtures/profile_script.js` is the compatibility regression: an overwrite written for the suite that
performs the transform real ones perform, so it exercises `Map`/`Set`, spread, destructuring, optional chaining,
nullish coalescing, `Object.fromEntries`, named capture groups and lookbehind in one pass. Keep it first-party and
free of external URLs — vendoring somebody's published script here carries their attribution and their links.
## Rust Helper Service
`services/helper/` is the privileged helper that starts the core elevated so TUN works. It ships on Windows and Linux
and is built by the setup build hook alongside the Core, which always compiles the Helper in Rust release mode
after calculating the SHA256 of the Core produced for the active Flutter configuration.
The helper owns its Windows Service Control Manager lifecycle through two elevated commands:
- `FlClashHelperService.exe install` stops and removes any stale registration, creates the auto-start service for the
current executable path, starts it, and waits for the running state.
- `FlClashHelperService.exe uninstall` stops the service, waits for shutdown, removes its registration, and is also used
by the Windows package uninstaller.
The Dart layer only launches the helper's `install` command through `ShellExecuteW`; it does not compose `sc.exe`,
`taskkill`, or `cmd.exe` command lines.
Linux takes the same shape with systemd in place of the Service Control Manager, and the same install timing: nothing
is registered at package install, and `Linux.registerService` asks for elevation only when TUN authorization needs it.
- `FlClashHelperService install`, run through `pkexec` so polkit raises the system prompt, writes
`/etc/systemd/system/flclash-helper.service` for the current executable path and enables and restarts it. It reads
`PKEXEC_UID`/`SUDO_UID` to learn who asked, and refuses to install without one — there would be no account to grant
the socket to. It also refuses a Helper whose binary or directory is not root-owned and non-writable (a unit runs it
as root at every boot, so an unpacked bundle would be a standing escalation), and refuses to replace a unit already
installed for a different UID rather than restart the service out from under that account.
- That ownership check is why the `flutter_distributor` fork normalizes the packaging tree to 0755/0644 before
`dpkg-deb`, `rpmbuild` and `appimagetool` run: they record modes verbatim, and Ubuntu's per-user default umask
of 002 would otherwise ship `/opt/FlClash` as 0775, which the installer rejects as group-writable.
- The rpm spec sets `debug_package` and `__os_install_post` to nil for the same reason: rpmbuild's find-debuginfo and
brp-strip rewrite `FlClashCore`, and a Core whose SHA256 no longer matches the Helper's embedded value is refused at
`/start`, which silently degrades every launch to the direct Core.
- `FlClashHelperService uninstall` disables the unit, removes it and reloads systemd.
- The unit carries `Group=` (the owner's primary GID), `RuntimeDirectory=flclash`, the owner's UID/GID in
`FLCLASH_HELPER_OWNER_UID`/`_GID`, a double-quoted `ExecStart=` with `%` escaped, and `Restart=on-failure` under a
start limit so a broken unit ends up failed instead of restarting forever. The helper serves
`/run/flclash/helper.sock` at mode `0660`, additionally drops any connection whose `SO_PEERCRED` UID is not the
owner's, logs and retries an `accept` failure instead of letting hyper end the server, and handles SIGTERM so
`systemctl stop` still runs its own Core teardown.
- `/start` additionally requires the Core address to be a socket owned by the owner UID before spawning, since the
Core connects to it as root.
- The Core is spawned with the owner's real UID and an effective UID of 0, which is what a setuid Core would have had.
It is the signal `core/ownership_unix.go` uses to hand the files it created back to the user; without it a root
service would leave a root-owned config tree in the owner's home.
- An AppImage has neither a stable executable path nor a writable Core, and its FUSE mount is `nosuid`, so
`system.isAppImage` reports TUN authorization as unavailable instead of prompting for a password that cannot help.
- A Linux host without systemd (`/run/systemd/system` absent) has no Helper: `system.hasHelperService` is false there,
readiness is the `stat` check, and `pkexec` sets the setuid bit on the bundled Core as before.
In every Flutter build mode `/start` opens the fixed Core executable beside the Helper without write/delete sharing,
validates it against the SHA256 embedded only in the Helper, and keeps that handle open through process creation.
`/ping` only compares the requested `coreSha256` with the Helper's embedded value and checks the fixed Core path exists;
it never hashes the Core. Protocol version 6 uses 32-character lowercase-hex session ownership:
- `GET /ping?coreSha256=...` returns the current Helper executable path with `x-flclash-helper-protocol` when the
requested SHA matches.
- `POST /start` rejects unknown JSON fields, validates `{address, sessionId}`, then releases any previously managed Core
before verifying the Core — so every outcome, including a rejected one, leaves the Helper owning no Core — and returns
`{sessionId, pid}`.
- `POST /stop` validates `{sessionId}` and only stops the matching managed Core. A session mismatch is HTTP 409.
- `GET /logs` exposes the bounded recent Helper/Core stderr buffer with `no-store` caching.
Endpoints bind only to `127.0.0.1:47890` on Windows and to `/run/flclash/helper.sock` on Linux, and do not use
request-token authentication. Lifecycle safety comes from the fixed executable/hash, the strict address namespace
(`\\.\pipe\FlClashCore_<32 hex>` on Windows, `/tmp/FlClashSocket_<digits>.sock` on Linux), the session-scoped stop
contract, Dart-side peer-PID verification on Windows and, on Unix, the Core socket that `plugins/rust_api` sets to
mode `0600` so only the owning user (and the root-effective Core) can connect. When the Helper service itself shuts
down, it unconditionally stops the Core process it owns; under systemd the unit's control group does the same.
+313
View File
@@ -0,0 +1,313 @@
# 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 Go core and the Rust helper build automatically: Flutter runs
`plugins/setup/hook/build.dart` on every `flutter build` and `flutter test`,
and the hook drives `CoreBuilder` from the `setup_hooks` package in
`plugins/setup/setup_hooks/`. Android builds take the NDK from the C compiler
Flutter hands the hook; no `ANDROID_NDK` variable is needed.
Artifacts land in `libclash/`. The hook reruns only when Go, Rust, or
`setup_hooks` inputs change; the fingerprint cache lives in
`.dart_tool/setup_build_cache/`. To force a rebuild, delete that directory:
```bash
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
code_forge:
build_assets: true # and for the code editor's Rust library
```
CI flips all three to `false` with `yq` before `dart run tool/changelog.dart` and
`flutter test`, and restores the file afterwards; no test loads any of the libraries.
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.
## 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. 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`. `pubspec.yaml` declares the two 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
```
`plugins/setup/setup_hooks/` is a pure Dart package and is tested with `dart test` from its own directory, which is
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
run of `plugins/setup` would execute the build hook itself.
Native plugin code under `plugins/*/{linux,macos,windows}` is compiled by the CI `desktop` job, which runs
`flutter build <platform> --debug` on every push; `tool/check_plugins.sh` only analyzes and tests the Dart side.
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`.
For the current Core/service architecture, useful focused checks are:
```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.
- `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:
```bash
cd core
CGO_ENABLED=0 go test .
CGO_ENABLED=0 go vet .
```
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 :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.
## Verify
In the public repository every branch push runs the `dart` job; in the private
`origin` every check job runs only when started by hand (`workflow_dispatch`).
The `dart` job performs these root-package checks in order:
```bash
bash tool/check_commit_msg_test.sh
bash tool/check_comment_density_test.sh
flutter pub get
dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart
flutter analyze --no-fatal-infos
dart run tool/changelog.dart verify # main and tags only
flutter test --reporter expanded --coverage
dart run tool/check_coverage.dart coverage/lcov.info 75
```
Run `flutter analyze` before committing; CI fails on anything it reports.
Release builds run only for `v*` tag pushes; pull requests trigger nothing.
Root analysis excludes `plugins/**`, and root tests do not discover nested
plugin packages, so parallel jobs validate the rest from their own package
directories: `plugins` (local Flutter packages and the setup build tool), `go`
(the Core wrapper, plus an NDK-backed vet of the Android files), `android`
(JVM unit tests for `:common`, `:service` and `:app`, with the Flutter compile
tasks excluded so no native build hook runs), `rust` (both crates), and a
Windows runner for the helper's `windows-service` feature. A `desktop` job also
compiles a debug build per desktop platform. Release builds run only in the
public repository and start once every job except `desktop` passes.
`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script.
It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
`flutter test` wherever `test/*_test.dart` exists. Adding a plugin package needs
no workflow edit; enumerating packages by hand in the workflow is what
previously left `plugins/tray` unanalyzed and untested.
## Worktree Tooling
```bash
bash tool/worktrees.sh list # every worktree with owner tool and dirty/clean state
bash tool/worktrees.sh prune # remove clean worktrees; add --force to drop dirty ones too
```
+115
View File
@@ -0,0 +1,115 @@
# Project Context
FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with Flutter. It supports Android, Windows, macOS, and Linux, using a Material You design with Surfboard-like UI.
## Version Notes
- Flutter stays on the 3.47.x line. Release CI pins 3.47.4 and is the source
of truth for release builds; `.fvmrc` and local SDKs may sit on another 3.47
patch. `code_forge` 10.13 and later need Dart 3.13.2, so the CI pin cannot go
below Flutter 3.47.2.
- 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
Three `pubspec.yaml` dependencies are pinned to a fork, two by commit SHA and
`code_forge` by tag. All three forks live under `chen08209`, the same account
that owns this repository, so they are maintained in-house rather than tracked
from a third party: advancing a pin is a local decision, and there is no external
maintainer to wait on for the patch itself. What each fork still waits on is the
*upstream* fix that would let the pin be dropped entirely, recorded below.
Each entry records what the fork changes and what has to be true before it can go
back to the published package, so a future upgrade does not have to rediscover it.
Re-verify a fork by diffing its pub cache checkout against the published version
of the same number:
```bash
diff -ru ~/.pub-cache/hosted/pub.dev/<name>-<version> ~/.pub-cache/git/<name>-<sha>
```
The `window_manager` fork (`chen08209/window_manager`, tag `v0.5.1-flclash.3`)
was replaced by the in-repo `plugins/window` package; the behaviors it carried
(work-area maximize with a hidden title bar on Windows, geometry restore on
re-map on Linux, corner preference, terminate and activate events) live there
now, see the window plugin section of `architecture.md`.
`launch_at_startup` — `chen08209/launch_at_startup`, version 0.5.1.
- Migrates `win32_registry` from `^2.0.0` to `^3.0.3`, which is a breaking rename
across the whole Windows implementation (`Registry.openPath` → `CURRENT_USER.open`,
`createValue` → `setValue`, `getStringValue` → `getString`).
- This one is not optional while it lasts: FlClash depends on `win32_registry: ^3.0.3`
directly, and upstream's `^2.0.0` constraint cannot co-resolve with it.
- Drop the fork when upstream publishes a release that accepts `win32_registry` 3.x.
`yaml_writer` — `chen08209/yaml_writer`, version 2.1.0.
- Adds `StringNode.quoteKey()` and applies it to map keys in `lib/src/node.dart`.
Upstream quotes values but emits keys verbatim, so a profile key needing quotes
is written as invalid YAML.
- Drop the fork once upstream quotes map keys by the same
`isValidUnquotedString` rule it already applies to values.
`code_forge` — `chen08209/code_forge`, tag `v10.14.0-flclash.4`, version 10.14.0.
A new pin is a new `v<upstream version>-flclash.N` tag on the fork's `main`.
- Builds the Rust crate through a `hook/build.dart` Native Assets hook, the same
`flutter_rust_bridge_hooks` path as `plugins/rust_api`, instead of the cargokit
FFI plugin. The platform folders and `cargokit/` are gone, so macOS needs no
CocoaPods fallback beside Swift Package Manager, and `rust/rust-toolchain.toml`
pins the same channel and targets as `rust_api`.
- The hook honours `hooks.user_defines.code_forge.build_assets`, and
`flutter_rust_bridge` is pinned to the exact version `rust_api` generates with,
because a codegen and runtime mismatch throws in `RustLib.init()`.
- `CodeForgeKeyboardShortcuts.forPlatform` gives Cmd/Option bindings on Apple
platforms, and `onContextMenu` and `scrollbarBuilder` let `lib/pages/editor.dart`
show `CommonPopupMenu` and `FloatingScrollbar` instead of the built-in menu and
scrollbar.
- Also fixes the caret drawn while read-only, word-left movement from the end of
a word, the `attached` assertion when a tap arrives after the input method
connection closed, and a caret left under the software keyboard when a tap
opens it.
- Each change is its own commit, so one upstream adopts can be dropped on its
own. The fork goes away only once upstream builds through Native Assets and has
the hooks above.
## Dependency Ceilings
`flutter pub outdated` lists only upstream-held entries, so treat them as
resolved-until-the-ceiling-moves rather than as debt:
- `intl` is intentionally unbounded (`any`) and `material_color_utilities` is
resolved by the Flutter SDK; neither is a bound this repository sets.
`freezed`, the `riverpod` set, `build_runner`, `drift_dev`, `intl_utils` and
`test` all resolve against the analyzer the pinned SDK ships; none of them is
held back. When an upgrade fails version solving, record the blocker here with
what has to change upstream before the pin can move, so the next
`flutter pub outdated` run does not rediscover it.
`_initDynamicColor` in `lib/bootstrap.dart` reads the `dynamic_color` method
channel directly and decodes the palette list into a `TonalPalette`, instead of
going through `DynamicColorPlugin.getCorePalette()` and its deprecated
`CorePalette` return type. Everything downstream sees `DynamicColorSeeds`, so a
non-deprecated plugin accessor changes only that one function.
## Build Dependencies
Linux:
```bash
sudo apt-get install libayatana-appindicator3-dev
```
Windows:
- GCC and Inno Setup.
macOS:
```bash
npm install -g appdmg
```
+524
View File
@@ -0,0 +1,524 @@
# 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.
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/widgets/open_container.dart`.
### Density
`tool/check_comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring
diffs under 20 added lines so small edits are never caught. The number is calibrated on this repository's own history:
healthy changes sit at or under 3.6%, while the core fix that prompted the gate ran 22.4%.
It is a ceiling on frequency, not a target to fill. Do not read `core/`'s inherited mihomo density as a quota either —
it is forked upstream code, not a house style.
Three gates run the same script, and they do not have equal force. The `PostToolUse` hooks in `.claude/settings.json`
and `.codex/config.toml` run after the tool and hand offending lines back as feedback; neither can undo the completed
write. The `comment-density` pre-commit hook fails the commit, and it is the only hard gate that covers every tool.
Behavior is pinned by `tool/check_comment_density_test.sh`, which CI runs directly.
When a change genuinely warrants more, raise the ceiling for that run rather than working around it:
`COMMENT_DENSITY_MAX=20 git commit`, or `SKIP=comment-density git commit` to step past it entirely.
### Where Knowledge Belongs
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/common.go`, and whoever changes that number must see the constraint on the same screen. This is the case a
comment is for; the bar is that no test and no `.agents/` entry could hold the fact instead.
Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;
leaving a repo-wide policy as a comment reaches only the reader of that one file.
## Core API Safety
- `core/Clash.Meta` is a fork of mihomo, and changes to it are budgeted for features, not repairs. Fixing a bug there is
low priority even when the bug is real and the fix is small: every patch is one more thing to carry across an upstream
rebase. Solve it on the FlClash side of the boundary and note the mihomo behaviour you are working around. Reach into
the submodule only for a feature that has nowhere else to live, or when the problem is one the FlClash patches
themselves introduced — and say which of the two it is in the commit message.
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
a scope-specific cleanup API instead.
- The desktop IPC socket in `plugins/rust_api` admits a Unix peer only when its effective uid is the app's own or root:
the socket lives in `/tmp`, and the Core connects as root whenever it runs setuid for TUN or under the Linux Helper.
A launch mode that runs the Core as any other user has to widen `authorize_peer` in `ipc/platform.rs`, and Windows
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, JNI, and
desktop IPC. Do not double-encode `arguments`, `result`, or event batches.
- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,
route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest
entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push
out the request events the connection list is built from; state uses `enqueueState`, which never evicts, because a dropped `geoUpdate{updating:false}` leaves `isUpdatingProvider` stuck at true in the UI until
`UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full
queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports,
and reporting it from the message layer feeds the same batcher.
- `jni_get_string` in `android/core/src/main/cpp/jni_helper.cpp` `malloc`s and hands ownership to Go, which frees through
`free_string_func`. `quickSetup` relies on that: it reads its `*C.char` arguments inside a goroutine, after the JNI
wrapper has already returned. Switching the wrapper to `GetStringUTFChars`/`ReleaseStringUTFChars`, or freeing on the
C side, turns that read into a use-after-free.
- Go goroutines reach Java through `ATTACH_JNI()`, which attaches once with `AttachCurrentThreadAsDaemon` and detaches
from a `pthread_key` destructor at thread death. Do not restore a detach-per-call: `protect` runs once per outbound
socket and `onResult` once per event batch, and attach/detach takes ART's thread-list lock each time.
- Every JNI call into Kotlin must be followed by `jni_clear_exception`. A pending exception left in place aborts the
process on the next JNI call on that thread, so a throw in `protect`/`resolveUid`/`resolvePackage`/`onResult` becomes a
crash in unrelated code. For the same reason every one of those wrappers checks its `tun_interface`/`invoke_interface`
for `nullptr` first: ART aborts on a call through a null object, and a callback released by `TunHandler.clear` while a
connection is still resolving is exactly that.
- The Android bridge resolves an owner in two steps — `resolve_uid` then `resolve_package` — because mihomo fills
`metadata.Uid` from a procfs lookup that Android Q closed off. Collapsing them back into one call that returns only a
package name is what left every connection reporting uid 0, so `UID` rules matched nothing.
- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to
the log subscriber, batched, and handed back to `send`, so a send failure reported that way feeds itself; use
`logDeliveryError`, which writes to stderr and latches until a frame gets through or the next connection is installed.
A write that fails without putting a byte on the wire — host backpressure hitting `ipcWriteTimeout`, or a payload above
`maxIPCFrameSize` — drops that one frame and keeps the connection: the stream is still framed correctly, and tearing it
down here ends the read loop, and with it the Core process. A half-written frame whose write merely timed out is
resumed for as long as the stall lasts: Windows Modern Standby suspends the app while the Helper's Core keeps running,
so the host can stop draining for hours and still come back, and go-winio reports the expiry as its own `ErrTimeout`
rather than `os.ErrDeadlineExceeded`, which is why `send` checks `Timeout()`. The wait has no cap on purpose, and
`send` holds `writeMu` throughout, so every other frame — method responses and the single batcher goroutine behind
the event queues — waits behind the stalled one. Memory is bounded by the queues; what gives is delivery: the state
queue fills and `enqueueState` starts dropping, which is the case the `UpdatingAction` sweep above exists for. Nothing
on the Dart side restarts the Core over a stall: `CoreRpcClient` times each pending request out on its own and hands
the caller `null` (a `no_response` exception for message methods), and the sweep clears core-scope updating state
minutes later. Only a half-written frame that fails outright desynchronizes the stream, and that is the one case
`send` closes on.
- Core method handlers in `core/hub.go` are synchronous. Anything that must not block the dispatcher is spawned by
`safeGo`/`safeGoDetached` in `core/method.go`, which recover; a bare `go` in a handler puts a panic outside every
recovery and kills the process, which on Android is the whole application. The `//export` entry points in
`core/lib.go` do not reach `handleMethodCall`, so each one carries its own recovery.
- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in
`core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the
socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func
value. Stopping the TUN swaps `activeTunHandler` instead.
- `tunnel.AllProxies()` returns a shared, cached map — never modify it. The cache is invalidated by
`invalidateAllProxies` on `tunnel.UpdateProxies` and validated against each provider's `Version()`, so a rebuild
costs one read per provider rather than one per proxy. Anything else added to `tunnel/patch.go` that derives from the
proxy set needs both signals: the external controller can reload the config through `hub/route/configs.go` without
going through FlClash's `applyConfig`, so a hook on the FlClash side alone would miss a profile switch.
- Core state that mirrors mihomo state goes stale at the next `applyConfig`, which replaces every proxy, provider and
rule. Read the tunnel instead of caching a snapshot of it: `lookupExternalProvider` kept one that was rebuilt only
when the host asked for the provider list, and the host asks after a successful setup and not after a failed one, so
an update ran against a provider the tunnel no longer held — downloading, writing to disk and reporting success
against nothing.
- Selection writes take `selectMu`, not `configMu`. mihomo's `Selector.Set` has no lock of its own, so the writes need
mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is
what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under
`configMu`, fixing the order as `configMu` → `selectMu`.
- Queueing for the delay-test semaphore and probing the node each get the full timeout in `handleTestDelay`. With one
shared deadline, a node that waited behind a saturated semaphore reached `URLTest` with almost nothing left, so a bulk
test of a large subscription reported whatever sat at the back of the queue as unreachable.
- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy:
`handleTestDelay` returns within its two timeouts on every path. `asyncTestDelay` therefore returns null instead of a
`-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a
timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's.
- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0
used to mean "testing", which let a result and the state of a test overwrite each other and left cards spinning
forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a
reply arriving; core status leaving `connected` cancels every run in flight.
- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background
scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the
batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData`
claims per resource and `updater.GeoUpdateHook` releases.
- A failed `applyConfig` rolls the tunnel back to the default config, and that rollback is the whole recovery:
`handleSetupConfig` returns the error and stops there. Do not add a teardown on top of it — stopping the listeners
takes the app offline over a profile the user can still switch away from, and the error already reaches the host,
which is what surfaces the failure (`MessageException` on the Flutter side, the config-error toast on Android). Keep
an empty `config.yaml` out of the failure path — it is how the app says "no profile selected", and `loadConfig`
resolves it to the defaults.
- Package `init` in the Android library runs while the `.so` is being loaded, so a panic there takes the application
down before it can report anything. `platform/limit.go` arms an fd-pressure probe and degrades to never blocking when
it cannot; keep that shape for anything else `init` sets up that correctness does not depend on.
- Every `android && cgo` file in `core/` is compiled only by the NDK-backed CI step in the `go` job. Keep the build
constraints as `android && cgo` / `!(android && cgo)`: a bare `cgo` constraint makes `go build ./...` fail in `core/`
on any developer machine, because the files it pulls in need the NDK.
## Lifecycle Rules
- Crash recovery is owned by `BootGuard` (`lib/common/boot_guard.dart`), and the signal it acts on is the persisted
`BootRecord`, never a crash reporter. The record is stamped `starting` before `startCore` and `running` once
`_initApp` finishes, so only a launch that died before reaching `running` is a failed launch — a crash after hours of
runtime, or a process the system reclaimed in the background, is not one. `ApplicationExitInfo` can only veto a
failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through
`handledExitAt` so the same exit cannot be counted twice.
- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and
`markClosed` return without touching preferences. The desktop has neither of the two attribution sources — no
`ApplicationExitInfo` equivalent and no Crashlytics toggle — so a bare sentinel there would read a window closed on
the disclaimer dialog, or a session ended by shutdown, as a failed launch. Keep the platform check inside the guard;
callers in `bootstrap.dart` and `SystemAction` stay unconditional.
- `FirebaseCrashlytics.didCrashOnPreviousExecution()` corroborates a failure and must not trigger one. Its marker file is
cleared by a background initialization that `dataCollectionArbiter` blocks while collection is disabled, which is the
default here, so one real crash makes it return true on every later launch. That latch is what made the app clear the
selected profile on every cold start; it is also why the probe is only read when `crashlytics` is enabled, keeping
Firebase uninitialized until the user consents.
- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from
`crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config.
- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets,
managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`.
- `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must
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.
- 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 now reflects the payload, and reports `false` instead of showing a broken
icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather
than suppressed until restart. Any test that mocks the `tray` channel must return `true` from `show`.
- Reading the Wi-Fi SSID is opt-in work, not ambient state. `ConnectivityManager` reads it only while `excludeSSIDs` is
non-empty, because that list is its only consumer through `suspendProvider`, and the read costs a blocking platform
call plus a location permission on Android and macOS. A second consumer must widen that gate, not drop it.
- On Android the `wifi_ssid` permission is only `granted` once `ACCESS_BACKGROUND_LOCATION` is held too (Q+). Fine
location alone reads the SSID in the foreground but returns `UNKNOWN_SSID` once the app is backgrounded, which is
where on-demand spends most of its time. `getSsid` itself only needs fine location, so the split stays inside the
plugin.
- No `wifi_ssid` implementation may answer `getSsid` on the platform thread. Android resolves through a network callback
with a timeout, Linux through `g_task_run_in_thread`, and macOS through `ssidQueue` after checking the location
authorization, because CoreWLAN reaches `wifid` over XPC and a wedged daemon would freeze the window. Windows is the
outstanding exception: `WlanQueryInterface` still runs inline, since replying off-thread there needs a window-message
hop that the plugin API does not provide.
- The delayed DNS re-check `NetworkObserveModule.onLosing` posts is deliberately left un-deduplicated. The runnable
re-reads `networkInfos` and does nothing when `onLost` already dropped the network, `updateDns` returns early when the
resolved list is unchanged, and `stop()` clears the handler queue, so a network that reports `onLosing` repeatedly
costs one comparison per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds
state that has to stay in sync with the map.
## Testing Rules
The `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage
collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper,
plus an NDK-backed `GOOS=android` vet that covers the `android && cgo` files the first two exclude; verify cross-language
protocol behavior through shared Dart contract tests under `test/core/` and native platform build checks.
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`.
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
path. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`.
Three globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it:
`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every
`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry
key), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early
under `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test
at all; `test/common/launch_test.dart` pins the early return instead.
`pumpAndSettle` never returns on a page holding `EditorView` (what `EditorPage` embeds): the code editor blinks its
caret forever, so frames keep being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in
`common/task.dart` hand work to a real isolate through `compute`, which only runs outside the fake-async zone, so a test
awaiting one needs `tester.runAsync(...)` between the pumps.
The `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it
replaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the
setter is not, so closing there would either be unawaited or force the seam to become async for no gain. A test that
does open a file-backed database owns closing it.
`system.isAndroid` / `isMacOS` / `isWindows` / `isLinux` read `dart:io` `Platform` and cannot be overridden, unlike
`debugDefaultTargetPlatformOverride`. A branch behind one of them is only ever exercised on a host that matches it, so CI
(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior,
and leave headroom under a group floor that covers such a branch.
A platform decision that drives layout takes `isDesktop`/`isMacOS` as parameters and reads `system` only at the call site,
so every platform's outcome is reachable from one host. `getWindowHeaderHeight` and `showsWindowHeader` in
`lib/common/layout.dart` own the window header rule for both `WindowHeaderContainer` and `overlayTopOffset` — they must
agree, or the content is offset by a header that is not there. `WindowHeaderBar` takes its height and slots as arguments,
which is what lets `test/manager/window_header_test.dart` measure the Windows caption bar on a macOS host.
A `Stack` under loose constraints sizes to its non-positioned children, and falls back to `constraints.biggest` only when
it has none — so a bar that fills the window must keep every slot positioned. One non-positioned child is enough to
collapse `WindowHeaderBar` to that child's width: the macOS title did exactly that, leaving the app name over the traffic
lights and the raw window painted black beside it, while Windows, whose slots are all positioned, stayed correct.
`WindowHeaderLayout` positions the header across the top for the same reason, and the macOS group asserts the bar's
width, not just its height — a height-only assertion cannot see this.
The same seam carries the two other places a platform decision changed what was built: `AppTray` holds `isMacOS`/
`isWindows` as state — `AppTray()` fills them from `system`, `AppTray.forPlatform` is the test seam — and `OnDemandView`
takes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because
`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS
shape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every
non-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window` channel mock that
answers `isMaximized`/`isAlwaysOnTop` with a bool.
Auto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,
`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching
rebuilds the provider from its override and silently discards whatever the code under test wrote. Add
`container.listen(theProvider, (_, _) {})` in the harness, as `overwrite_stage_flow_test.dart` does. The staging flow also
re-arms its debounce when it clears the stage, so drain it (`pump` past the duration, then unmount) or the binding fails
the test on a pending timer.
A field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`,
`PageController`, `AnimationController` or `StreamController` must be released in the same file.
`test/lint/disposable_field_test.dart` enforces this by scanning `lib/`, because no lint covers it: `close_sinks` only sees
sinks, and nothing in the standard set tracks `ChangeNotifier` disposal. A field that genuinely outlives its owner goes in
that test's `_allowed` set with the reason, not left bare. Controllers received as widget parameters belong to the caller
and are out of scope.
An `IconButton` whose icon is an icon needs a `tooltip`. It is the button's only accessible name — without it TalkBack and
VoiceOver announce nothing and the desktop build shows no hover hint. `test/lint/icon_button_tooltip_test.dart` enforces
it and skips exactly two shapes: an `icon:` holding a `Text`, which is already a visible label, and
`views/dashboard/widgets/core_status_button.dart`, which takes its label from an enclosing `Tooltip` (a second test fails
if that wrapper disappears). Reuse an existing string before adding one; a label that depends on state goes on the button
inside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden
behind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern.
A tooltip needs an `Overlay` ancestor at build time, not at hover time: `RawTooltip` builds an `OverlayPortal`, so a
button whose `tooltip` has nowhere to go throws "No Overlay widget found" and takes its whole subtree down with it.
Everything `buildManagerStack` wraps around `MaterialApp.builder`'s child sits *above* the app Navigator and therefore
above the only Overlay in the tree. A manager that renders a tooltip — `WindowHeaderLayout` and its caption buttons are
the case that broke Windows while macOS, which renders a bare title there, stayed clean — hosts its own with
`Overlay.wrap`, spanning the window so the tooltip is not clipped to the widget that owns it. A test only reproduces this
by mirroring that topology: build the widget from `MaterialApp.builder`, never from `home:`.
A public top-level declaration that nothing outside its own file references is dead, and no lint catches it:
`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every
"unused import" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the
`final appPath = AppPath()` singletons next to them — appear nowhere else, counting generated code as a consumer (a
riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or
typedefs are skipped: those are reached through the types they attach to, never by name.
A `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then
immediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown
throws "`…State.dispose failed to call super.dispose.`" in debug and profile builds. Declare the override as `void
dispose()` and hand async teardown to `unawaited(...)`; `Future<void> dispose() async` compiles and is the shape that
invites the bug.
Use `ProviderContainer` directly for simple Riverpod provider tests, released with `addTearDown(container.dispose)`.
Assert against the provider's generated public API, not its implementation, re-read source defaults before asserting
them, and wait on provider futures or state changes rather than fixed sleeps. The generated Riverpod `update()` method
takes a callback:
```dart
notifier.update((state) => newValue);
```
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/check_commit_msg.sh`:
```text
<type>[(scope)][!]: <description>
```
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.
- Scope is optional and lower case; use a comma to list several, as in `fix(core,android)`.
- `!` before the colon marks a breaking change.
- Descriptions start in lower case, omit the trailing period, and keep the whole subject within 100 characters.
Identifiers and acronyms keep their own casing, as in `fix(ui): AppBar text is truncated`.
- `Merge`/`Revert` subjects and `fixup!`/`squash!` commits are exempt.
- No `Co-authored-by` trailer crediting a coding agent, whatever that tool's own convention says. The history
records who owns the change, not which tool typed it; human co-authors are still fine. The hook rejects the
known agent identities.
Write what the change does, not that something changed: `perf(views): stop redoing per-frame work in build`, not
`Optimize more details`.
Install the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`.
### Changelog Trailers
`tool/changelog.dart` builds the user facing changelog from the commit history, so the trailers below are the copy that
ships to users. The subject stays the developer facing summary and is only the fallback.
```text
feat(profiles): support per-profile override script
Changelog: Per-profile override scripts
```
- `Changelog:` is the English entry. `Changelog: skip` drops the commit from the changelog entirely.
- The changelog is English only. A `Changelog-<locale>:` or `Breaking-<locale>:` trailer fails the hook, because
translated release copy does not belong in the commit history. Translate after the fact if ever needed, not in the
commit message.
- `Changelog-Type:` moves an entry into another group, for example to promote a `refactor` that users will notice. Valid
values are `breaking`, `feat`, `fix`, `perf`, `revert`.
- `BREAKING CHANGE:` is required whenever the subject carries `!`, and its text becomes the breaking entry. A `!` commit
therefore needs two lines of copy: the footer for the breaking entry and `Changelog:` for the normal one.
`feat`, `fix`, `perf`, `revert` and breaking commits are collected by default; every other type is dropped unless it
carries a `Changelog:` trailer. Commits missing a trailer reuse their subject; for `feat`, `fix` and `perf` the hook
says so without blocking.
## Generated Code
Do not manually edit generated files under:
- `lib/l10n/l10n.dart`
- `lib/models/generated/`
- `lib/providers/generated/`
- `lib/database/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}.arb` — flat JSON, no `@` metadata. Add a key to all four, then regenerate with
`dart run intl_utils:generate`, which rewrites `lib/l10n/`. A key present in only some locales silently falls back to
English at runtime, so add the translation rather than leaving it out.
Some labels are not reached through the generated `AppLocalizations` getters at all. `Intl.message(<runtime string>)`
builds the key from an enum name or a stored string — `action_${HotAction.name}`, `${DynamicSchemeVariant.name}Scheme`,
`NavigationItem.description`. The analyzer sees nothing, and a failed lookup returns the key itself, so a stale key ships
as `routeMode_config` in the UI rather than throwing. `test/lint/dynamic_message_key_test.dart` expands those families
from the real enums and fails when a derived key is missing from any locale; every `Intl.message` site in `lib` must be
registered there, so a new dynamic key cannot be added without also declaring what builds it.
+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.
+63
View File
@@ -0,0 +1,63 @@
---
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, JNI ownership, `BootGuard`, tray, and SSID rules, and every
lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,
optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).
- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,
Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,
the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.
## 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`.
+45
View File
@@ -0,0 +1,45 @@
---
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`
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 covers the labels built from a runtime key, such as `action_${HotAction.name}`, which the analyzer cannot
see; the ARB section of `.agents/rules.md` explains them.
7. Re-run the Han-text scan for targeted strings.
## Pitfalls
- If ja/ru still show English, fix ja/ru source ARB values and regenerate. Do not edit generated Dart.
- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.
@@ -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.
+105
View File
@@ -0,0 +1,105 @@
---
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
Material You, Surfboard-like look the rest of the app has. Do not introduce a visual system for one screen or rewrite
a layout the request does not need.
- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,
and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not
await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).
- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows
section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.
- Every icon lives in `lib/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it
with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled
button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the
fill animation, and when to fill.
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
keep scrims from stacking.
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
- User-facing text goes through ARB; use `localization` when text changes are non-trivial.
- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a
minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in
`finally` for animations and timers. `CoreStatusButton` and the proxies delay test action are the reference implementations,
described under User-Facing Core And Delay Feedback in `.agents/architecture.md`.
## Corner Radii
All corner radii come from `lib/common/shape.dart`: a token, a derived value, or a named off-scale value passed to its
builders. Never write a bare radius literal at a call site.
The scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 logical pixels tall
reads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.
| Token | Value | Shortest side | Use |
| --- | --- | --- | --- |
| `none` | 0 | - | square edges, and the flat side of a grouped run |
| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |
| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |
| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups, FAB |
| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |
| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |
| `xxl` | 28 | over 200 | sheets, dialogs, full-screen containers |
| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |
- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`
builders; `AppShape` mirrors it as `RoundedSuperellipseBorder`, `full` included, plus `circle`, `input`, and the
`all`/`top`/`vertical`/`of` builders. Use `AppShape.full` for pills, never `StadiumBorder`; the Corner Radius
section of `.agents/rules.md` explains why and lists the superellipse API per layer.
- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever
the size comes from a `LayoutBuilder` or scales with text.
- `ThemeData.withAppShapes` in `lib/common/shape.dart` already applies the scale to card, dialog, bottom sheet, snack
bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon
button themes. Do not restate those shapes at call sites, and
leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its
corner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`
(`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy
group header in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the
one-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card at `ProxyCardType.min` is only about
64 tall, which caps its radius at 21, so `xl` would break on a setting the user can change at any time while `lg`
clears all three heights and the smallest dashboard tile.
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts
the floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8
up. `AppInputBorder` clips the notch region and paints the full superellipse through it, as Flutter's own
`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.
Nested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute
the outer value, as `_cardRadius` in `lib/widgets/popup.dart`, `thumbRadius + _kThumbInset` in `lib/widgets/tab.dart`, and the
selection ring in `lib/widgets/palette.dart` do. Adding a token for one of these is how a scale grows without bound.
A component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/widgets/sidebar.dart` does with 10, between the
chip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.
## Verification
- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,
including the exception path of any async action. Run `flutter analyze` and `flutter test test/widgets/` plus the
tests of the touched view.
- The scans in `test/lint/` fail on the common UI mistakes and run as part of `flutter test`: an `IconButton` whose
icon is an icon needs a `tooltip`; a field that constructs its own controller, notifier, or focus node is released in
the same file; a public declaration nothing references is dead. `.agents/rules.md` explains each rule and its
allowlist.
## Pitfalls
- Do not 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.
+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/check_comment_density.sh\"",
"timeout": 15,
"statusMessage": "Checking comment density"
}
]
}
]
}
}
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/core-platform
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/localization
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/pre-commit-quality-review
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/ui-work
+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/check_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"
]
}
}
}
+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
+39
View File
@@ -0,0 +1,39 @@
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 do
system_command "xattr",
args: ["-rd", "com.apple.quarantine", "#{appdir}/FlClash.app"]
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
+58
View File
@@ -0,0 +1,58 @@
<div align=center>
[![Release Downloads](https://img.shields.io/github/downloads/chen08209/FlClash/vVERSION/total?style=flat-square&logo=github)](https://img.shields.io/github/downloads/chen08209/FlClash/vVERSION/)
</div>
**Download based on your OS:**
<div align=left>
<table>
<thead align=left>
<tr>
<th>OS</th>
<th>Download</th>
</tr>
</thead>
<tbody align=left>
<tr>
<td>Android</td>
<td>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-android-arm64-v8a.apk"><img src="https://img.shields.io/badge/APK-ARMv8-168039.svg?logo=android"></a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-android-armeabi-v7a.apk"><img src="https://img.shields.io/badge/APK-ARMv7-45bf55.svg?logo=android"></a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-android-x86_64.apk"><img src="https://img.shields.io/badge/APK-x64-96ed89.svg?logo=android"></a>
</td>
</tr>
<tr>
<td>Windows</td>
<td>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-windows-amd64-setup.exe"><img src="https://img.shields.io/badge/Setup-x64-2d7d9a.svg?logo=windows"></a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-windows-amd64.zip"><img src="https://img.shields.io/badge/Portable-x64-67b7d1.svg?logo=windows"></a>
</td>
</tr>
<tr>
<td>macOS</td>
<td>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-macos-arm64.dmg"><img src="https://img.shields.io/badge/DMG-Apple%20Silicon-%23000000.svg?logo=apple"></a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-macos-amd64.dmg"><img src="https://img.shields.io/badge/DMG-Intel%20X64-%2300A9E0.svg?logo=apple"></a><br>
</td>
</tr>
<tr>
<td>Linux</td>
<td>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.AppImage"><img src="https://img.shields.io/badge/AppImage-x64-f84e29.svg?logo=linux"> </a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.deb"><img src="https://img.shields.io/badge/DebPackage-x64-FF9966.svg?logo=debian"> </a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.rpm"><img src="https://img.shields.io/badge/RpmPackage-x64-F1B42F.svg?logo=redhat"> </a>
</td>
</tr>
</tbody>
</table>
</div>
<div dir="ltr">
**List of all changes:** [ChangeLog](https://github.com/chen08209/FlClash/blob/main/CHANGELOG.md)
</div>
+558
View File
@@ -0,0 +1,558 @@
name: build
on:
push:
branches:
- '**'
tags:
- 'v*'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }}
env:
IS_STABLE: ${{ !contains(github.ref, '-') }}
FLUTTER_VERSION: '3.47.4'
GO_VERSION: '1.26.4'
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 commit message hook
run: bash tool/check_commit_msg_test.sh
- name: Test comment density hook
run: bash tool/check_comment_density_test.sh
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- 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 native build hooks
run: |
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false | .hooks.user_defines.code_forge.build_assets = false' pubspec.yaml
- name: Verify changelog
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
run: dart run tool/changelog.dart verify
- name: Run tests
run: flutter test --reporter expanded --coverage
- name: Check coverage
run: dart run tool/check_coverage.dart coverage/lcov.info 75
plugins:
name: Plugins
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Validate local Flutter packages
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: *checks
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux
os: ubuntu-latest
- platform: windows
os: windows-2022
- platform: macos
os: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: core/go.sum
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
key: desktop-${{ matrix.platform }}
workspaces: |
services/helper
plugins/rust_api/rust
cache-directories: .dart_tool/hooks_runner
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Linux build dependencies
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libsecret-1-dev
- name: Setup Xcode
if: matrix.platform == 'macos'
run: |
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
xcodebuild -version
- name: Enable git long paths (Windows)
if: matrix.platform == 'windows'
run: git config --global core.longpaths true
- name: Install dependencies
run: flutter pub get
# Compiles the desktop runners and the native plugin code that only the
# tagged release matrix built before; the bundle itself is discarded.
- name: Build debug bundle
run: flutter build ${{ matrix.platform }} --debug
go:
name: Go core
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: core/go.sum
- name: Validate Go core wrapper
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 ./platform ./tun)" || {
gofmt -l ./*.go ./platform ./tun >&2
exit 1
}
go test -count=1 .
go vet .
GOOS=linux go vet ./platform
- name: Setup NDK
uses: nttld/setup-ndk@v1
id: ndk
with:
ndk-version: ${{ env.NDK_VERSION }}
- name: Validate the Android core library
working-directory: core
env:
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: |
# Nothing else compiles the `android && cgo` files before a `v*` tag.
CC="$(echo "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/aarch64-linux-android21-clang)"
test -x "$CC" || { echo "NDK clang not found at $CC" >&2; exit 1; }
tags="$(sed -n 's/^tags: *//p' ../build_config.yaml)"
test -n "$tags" || { echo 'Could not read tags from build_config.yaml' >&2; exit 1; }
export CGO_ENABLED=1 GOOS=android GOARCH=arm64 CC
go vet "-tags=$tags" . ./tun ./platform
android:
name: Android unit tests
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Setup NDK
uses: nttld/setup-ndk@v1
id: ndk
with:
ndk-version: ${{ env.NDK_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: Run Android unit tests
working-directory: android
env:
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: |
{
echo "flutter.sdk=$FLUTTER_ROOT"
echo "sdk.dir=$ANDROID_HOME"
} > local.properties
wrapper="$FLUTTER_ROOT/bin/cache/artifacts/gradle_wrapper"
test -d "$wrapper" || flutter precache
cp -R "$wrapper/." .
chmod +x gradlew
# `:core` only calls System.loadLibrary; the excluded Flutter tasks would run
# the native build hooks for every debug ABI, which no JVM unit test consumes.
./gradlew :common:testDebugUnitTest :service:testDebugUnitTest :app:testDebugUnitTest \
-x :app:compileFlutterBuildDebug -x :app:copyFlutterAssetsDebug
rust:
name: Rust
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: |
services/helper
plugins/rust_api/rust
- name: Check format
run: |
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
cargo fmt --manifest-path plugins/rust_api/rust/Cargo.toml -- --check
- name: Run tests
run: |
cargo test --manifest-path services/helper/Cargo.toml
cargo test --manifest-path plugins/rust_api/rust/Cargo.toml
windows-helper-test:
name: Windows helper test
if: *checks
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: services/helper
- name: Check format
run: cargo fmt --manifest-path services/helper/Cargo.toml -- --check
- 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')
needs: [ dart, plugins, go, android, rust, windows-helper-test ]
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- platform: android
os: ubuntu-latest
- platform: windows
os: Windows-2022
arch: amd64
- platform: linux
os: ubuntu-22.04
arch: amd64
- platform: macos
os: macos-15-intel
arch: amd64
- platform: macos
os: macos-latest
arch: arm64
- platform: windows
os: windows-11-arm
arch: arm64
- platform: linux
os: ubuntu-24.04-arm
arch: arm64
steps:
- name: Setup rust
if: startsWith(matrix.os, 'windows-11-arm')
shell: pwsh
run: |
Invoke-WebRequest -Uri "https://win.rustup.rs/aarch64" -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable
$cargoPath = "$env:USERPROFILE\.cargo\bin"
Add-Content $env:GITHUB_PATH $cargoPath
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Android Signing
if: startsWith(matrix.platform,'android')
run: |
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
echo "${{ secrets.SERVICE_JSON }}" | base64 --decode > android/app/google-services.json
{
echo "keyAlias=${{ secrets.KEY_ALIAS }}"
echo "storePassword=${{ secrets.STORE_PASSWORD }}"
echo "keyPassword=${{ secrets.KEY_PASSWORD }}"
} >> android/local.properties
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: core/go.sum
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.platform }}-${{ matrix.arch || 'all' }}
workspaces: services/helper
cache-directories: .dart_tool/hooks_runner
- name: Setup Gradle
if: startsWith(matrix.platform,'android')
uses: gradle/actions/setup-gradle@v6
- name: Enable git long paths (Windows)
if: startsWith(matrix.os, 'windows')
run: git config --global core.longpaths true
# Stable publishes no Windows or Linux arm64 archive; master makes the action
# clone the SDK at the pinned tag and let it fetch the host's own Dart SDK.
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: master
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Setup Xcode
if: startsWith(matrix.platform,'macos')
run: |
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
xcodebuild -version
- name: Setup NDK
if: startsWith(matrix.platform,'android')
uses: nttld/setup-ndk@v1
id: build-ndk
with:
ndk-version: ${{ env.NDK_VERSION }}
add-to-path: true
link-to-sdk: true
- name: Get Flutter Dependency
run: |
flutter --version
flutter pub get
- name: Setup
env:
ANDROID_NDK_HOME: ${{ steps.build-ndk.outputs.ndk-path }}
run: dart setup.dart ${{ matrix.platform }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }} -v
- name: Upload
uses: actions/upload-artifact@v4
with:
name: artifact-${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) }}
path: ./dist
overwrite: true
upload:
permissions:
contents: write
needs: [ build ]
runs-on: ubuntu-latest
services:
telegram-bot-api:
image: aiogram/telegram-bot-api:latest
env:
TELEGRAM_API_ID: ${{ secrets.TELEGRAM_API_ID }}
TELEGRAM_API_HASH: ${{ secrets.TELEGRAM_API_HASH }}
ports:
- 8081:8081
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download
uses: actions/download-artifact@v4
with:
path: ./dist/
pattern: artifact-*
merge-multiple: true
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Skip native build hooks
run: |
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false | .hooks.user_defines.code_forge.build_assets = false' pubspec.yaml
- name: Generate release notes
shell: bash
env:
IS_STABLE: ${{ env.IS_STABLE }}
TAG: ${{ github.ref_name }}
run: |
target=()
if [[ "$IS_STABLE" == 'true' ]]; then
target=(--tag "$TAG")
else
dart run tool/changelog.dart build --unreleased
fi
dart run tool/changelog.dart render release "${target[@]}" --out release.md
dart run tool/changelog.dart render telegram "${target[@]}" --out telegram.md
- name: Push to telegram
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TAG: ${{ github.ref_name }}
RUN_ID: ${{ github.run_id }}
run: |
python -m pip install --upgrade pip
pip install requests
python release_telegram.py
- name: Patch release.md
run: |
version=$(echo "${{ github.ref_name }}" | sed 's/^v//')
sed "s|VERSION|$version|g" ./.github/release_template.md >> release.md
- name: Generate sha256
if: ${{ env.IS_STABLE == 'true' }}
run: |
cd ./dist
sha256sum ./* > SHA256SUMS
- name: Release
if: ${{ env.IS_STABLE == 'true' }}
uses: softprops/action-gh-release@v2
with:
files: ./dist/*
body_path: './release.md'
- name: Create Homebrew Cask Source Dir
if: ${{ env.IS_STABLE == 'true' }}
run: |
version=$(echo "${{ github.ref_name }}" | sed 's/^v//')
arm_sha=$(sha256sum "./dist/FlClash-${version}-macos-arm64.dmg" | awk '{print $1}')
amd_sha=$(sha256sum "./dist/FlClash-${version}-macos-amd64.dmg" | awk '{print $1}')
mkdir -p ./brew/Casks
sed \
-e "s|VERSION|$version|g" \
-e "s|ARM_SHA256|$arm_sha|g" \
-e "s|AMD_SHA256|$amd_sha|g" \
./.github/homebrew_cask_template.rb > ./brew/Casks/flclash.rb
- name: Push to Homebrew tap
if: ${{ env.IS_STABLE == 'true' }}
uses: cpina/github-action-push-to-another-repository@v1.7.2
env:
SSH_DEPLOY_KEY: ${{ secrets.HOMEBREW_SSH_DEPLOY_KEY }}
with:
source-directory: ./brew/
destination-github-username: chen08209
destination-repository-name: homebrew-tap
user-name: 'chen08029'
user-email: 'chen08209@gmail.com'
target-branch: main
commit-message: Update Homebrew cask from ${{ github.ref_name }}
target-directory: /
- name: Create Fdroid Source Dir
if: ${{ env.IS_STABLE == 'true' }}
run: |
mkdir -p ./tmp
cp ./dist/*android-arm64-v8a* ./tmp/ || true
echo "Files copied successfully"
- name: Push to fdroid repo
if: ${{ env.IS_STABLE == 'true' }}
uses: cpina/github-action-push-to-another-repository@v1.7.2
env:
SSH_DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
with:
source-directory: ./tmp/
destination-github-username: chen08209
destination-repository-name: FlClash-fdroid-repo
user-name: 'chen08029'
user-email: 'chen08209@gmail.com'
target-branch: main
commit-message: Update from ${{ github.ref_name }}
target-directory: /tmp/
-124
View File
@@ -1,124 +0,0 @@
name: build
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- platform: android
os: ubuntu-latest
- platform: windows
os: windows-latest
- platform: linux
os: ubuntu-latest
- platform: macos
os: macos-13
steps:
- name: Check Matrix
run: |
echo "Running on ${{ matrix.os }}"
echo "Arch: ${{ runner.arch }}"
gcc --version
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup JAVA
if: startsWith(matrix.platform,'android')
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
- name: Setup NDK
if: startsWith(matrix.platform,'android')
uses: nttld/setup-ndk@v1
id: setup-ndk
with:
ndk-version: r26b
add-to-path: true
link-to-sdk: true
- name: Setup Android Signing
if: startsWith(matrix.platform,'android')
run: |
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/local.properties
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/local.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/local.properties
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: 'core/go.mod'
cache-dependency-path: |
core/go.sum
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.x'
channel: 'stable'
cache: true
- name: Get Flutter Dependency
run: flutter pub get
- name: Setup
run: |
dart setup.dart ${{ matrix.platform }}
- name: Upload
uses: actions/upload-artifact@v4
with:
name: artifact-${{ matrix.platform }}
path: ./dist
retention-days: 1
overwrite: true
upload-release:
if: ${{ !contains(github.ref, '+') }}
permissions: write-all
needs: [ build ]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download
uses: actions/download-artifact@v4
with:
path: ./dist/
pattern: artifact-*
merge-multiple: true
- name: Pre Release
run: |
pip install gitchangelog pystache mustache markdown
pre=$(curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" | grep -Po '"tag_name": "\K.*?(?=")' || echo "")
if [ -z "pre" ]; then
echo "init" > release.md
else
current="${{ github.ref_name }}"
echo -e "\n\n<details markdown=1><summary>All changes from $current to the latest commit:</summary>\n\n" >> release.md
gitchangelog "${pre}.." >> release.md 2>&1 || echo "Error in gitchangelog"
echo -e "\n\n</details>" >> release.md
fi
- name: Release
uses: softprops/action-gh-release@v2
with:
files: ./dist/*
body_path: './release.md'
+32 -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,39 @@ 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.*
/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
@@ -2,7 +2,3 @@
path = core/Clash.Meta
url = git@github.com:chen08209/Clash.Meta.git
branch = FlClash
[submodule "plugins/flutter_distributor"]
path = plugins/flutter_distributor
url = git@github.com:chen08209/flutter_distributor.git
branch = FlClash
+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/check_comment_density.sh
language: system
files: \.(dart|kt|kts|swift|go|rs|java|cpp|cc|h|hpp|gradle|ya?ml)$
stages: [pre-commit]
- id: flutter-analyze
name: flutter analyze (pre-push)
entry: flutter analyze --no-fatal-infos
language: system
pass_filenames: false
stages: [pre-push]
- id: commit-msg-format
name: conventional commit message
entry: tool/check_commit_msg.sh
language: system
stages: [commit-msg]
+7
View File
@@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="main.dart" 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>
+13
View File
@@ -0,0 +1,13 @@
{
"lsp": {
"gopls": {
"binary": {
"env": {
"CGO_ENABLED": "0",
// "GOOS": "android",
// "GOARCH": "arm64",
},
},
},
},
}
+68
View File
@@ -0,0 +1,68 @@
# 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/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/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/common/shape.dart`,
never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or
`InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of
[.agents/rules.md](.agents/rules.md).
- Follow `lint_options.yaml` (included by every `analysis_options.yaml`), especially single quotes, trailing commas, `child:` last, no `print()`, const/final
preferences, and declared return types.
- 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.
+735
View File
@@ -0,0 +1,735 @@
# Changelog
## v0.8.98 (2026-09-14)
**Bug Fixes**
- **resources** Refresh the geo file size and time after an update finishes (c5bf5bd)
- **core** Keep the core running while Windows sleeps with the app suspended (60f371a)
## v0.8.97 (2026-09-10)
**Features**
- **ui** Rework the app UI and refresh the localization (26cfbaf)
- **app** Rework the app layer and window handling, and add proxy authentication (aaf934c)
- **desktop** Rework the desktop runners, packaging, and native build (c0fcbc0)
- **android** Rework the Android VPN service and lifecycle handling (ae29f38)
- **plugins** Rework the desktop plugins and add the Helper service and Rust bridge (adf715f)
- **core** Rework the core IPC and process lifecycle (c6eaa0a)
<!-- changelog:frozen -->
<!-- Entries below predate the structured pipeline. Their wording is kept as written; only the heading and list style were normalized. -->
## v0.8.96 (2026-08-17)
- Optimize commented policy
- Fix whole group delay test failing on Windows
- Optimize package icon loading and connections polling
## v0.8.95 (2026-08-14)
- Optimize core service
- Optimize Android TV launcher icon
- Optimize back navigation
- Optimize more details
- Fix some issues
- Optimize app layout
- Optimize focus control
- Adjust android process
## v0.8.94 (2026-07-11)
- Fix macos performance issue
- Support custom global-ua
- Update core
- Optimize some details
- Fix linux silent launching not working
## v0.8.93 (2026-05-29)
- Support custom overwrite
- Support run on demand
- Optimize windows ipc
- Optimize windows arm64
- Optimize build
- Optimize some details
- Update core
## v0.8.92 (2026-02-02)
- Add sqlite store
- Optimize android quick action
- Optimize backup and restore
- Optimize more details
## v0.8.91 (2025-12-12)
- Fix windows some issues
- Optimize overwrite handle
- Optimize access control page
- Optimize some details
## v0.8.90 (2025-10-08)
- Fix android tile service
- Support append system DNS
- Fix some issues
- Update changelog
## v0.8.89 (2025-09-27)
- Fix some issues
- Optimize Windows service mode
- Update core
- Update changelog
## v0.8.88 (2025-09-23)
- Add android separates the core process
- Support core status check and force restart
- Optimize proxies page and access page
- Update flutter and pub dependencies
- Update go version
- Optimize more details
- Update changelog
## v0.8.87 (2025-07-29)
- Optimize desktop view
- Optimize logs, requests, connection pages
- Optimize windows tray auto hide
- Optimize some details
- Update core
- Update changelog
## v0.8.86 (2025-06-15)
- Fix windows tun issues
- Optimize android get system dns
- Optimize more details
- Update changelog
## v0.8.85 (2025-06-07)
- Support override script
- Support proxies search
- Support svg display
- Optimize config persistence
- Add some scenes auto close connections
- Update core
- Optimize more details
## v0.8.84 (2025-05-01)
- Fix windows service verify issues
- Update changelog
## v0.8.83 (2025-05-01)
- Add windows server mode start process verify
- Add linux deb dependencies
- Add backup recovery strategy select
- Support custom text scaling
- Optimize the display of different text scale
- Optimize windows setup experience
- Optimize startTun performance
- Optimize android tv experience
- Optimize default option
- Optimize computed text size
- Optimize hyperOS freeform window
- Add developer mode
- Update core
- Optimize more details
- Add issues template
- Update changelog
## v0.8.82 (2025-04-18)
- Optimize android vpn performance
- Add custom primary color and color scheme
- Add linux nad windows arm release
- Optimize requests and logs page
- Fix map input page delete issues
- Update changelog
## v0.8.81 (2025-04-08)
- Add rule override
- Update core
- Optimize more details
- Update changelog
## v0.8.80 (2025-03-10)
- Optimize dashboard performance
- Fix some issues
- Fix unselected proxy group delay issues
- Fix asn url issues
- Update changelog
## v0.8.79 (2025-03-07)
- Fix tab delay view issues
- Fix tray action issues
- Fix get profile redirect client ua issues
- Fix proxy card delay view issues
- Add Russian, Japanese adaptation
- Fix some issues
- Update changelog
## v0.8.78 (2025-03-05)
- Fix list form input view issues
- Fix traffic view issues
- Update changelog
## v0.8.77 (2025-03-05)
- Optimize performance
- Update core
- Optimize core stability
- Fix linux tun authority check error
- Fix some issues
- Fix scroll physics error
- Update changelog
## v0.8.75 (2025-02-09)
- Add windows storage corruption detection
- Fix core crash caused by windows resource manager restart
- Optimize logs, requests, access to pages
- Fix macos bypass domain issues
- Update changelog
## v0.8.74 (2025-02-03)
- Fix some issues
- Update changelog
## v0.8.73 (2025-02-02)
- Update popup menu
- Add file editor
- Fix android service issues
- Optimize desktop background performance
- Optimize android main process performance
- Optimize delay test
- Optimize vpn protect
- Update changelog
## v0.8.72 (2025-01-10)
- Update core
- Fix some issues
- Update changelog
## v0.8.71 (2025-01-09)
- Remake dashboard
- Optimize theme
- Optimize more details
- Update flutter version
- Update changelog
## v0.8.70 (2024-12-09)
- Support better window position memory
- Add windows arm64 and linux arm64 build script
- Optimize some details
## v0.8.69 (2024-12-06)
- Remake desktop
- Optimize change proxy
- Optimize network check
- Fix fallback issues
- Optimize lots of details
- Update change.yaml
- Fix android tile issues
- Fix windows tray issues
- Support setting bypassDomain
- Update flutter version
- Fix android service issues
- Fix macos dock exit button issues
- Add route address setting
- Optimize provider view
- Update changelog
- Update CHANGELOG.md
## v0.8.67 (2024-11-09)
- Add android shortcuts
- Fix init params issues
- Fix dynamic color issues
- Optimize navigator animate
- Optimize window init
- Optimize fab
- Optimize save
## v0.8.66 (2024-10-26)
- Fix the collapse issues
- Add fontFamily options
## v0.8.65 (2024-10-26)
- Update core version
- Update flutter version
- Optimize ip check
- Optimize url-test
## v0.8.64 (2024-10-12)
- Update release message
- Init auto gen changelog
- Fix windows tray issues
- Fix urltest issues
- Add auto changelog
- Fix windows admin auto launch issues
- Add android vpn options
- Support proxies icon configuration
- Optimize android immersion display
- Fix some issues
- Optimize ip detection
- Support android vpn ipv6 inbound switch
- Support log export
- Optimize more details
- Fix android system dns issues
- Optimize dns default option
- Fix some issues
- Update readme
## v0.8.60 (2024-09-17)
- Fix build error2
- Fix build error
- Support desktop hotkey
- Support android ipv6 inbound
- Support android system dns
- fix some bugs
## v0.8.59 (2024-09-09)
- Fix delete profile error
## v0.8.58 (2024-09-08)
- Fix submit error 2
- Fix submit error
- Optimize DNS strategy
- Fix the problem that the tray is not displayed in some cases
- Optimize tray
- Update core
- Fix some error
## v0.8.57 (2024-09-02)
- Fix tun update issues
- Add DNS override
- Fixed some bugs
- Optimize more detail
- Add Hosts override
## v0.8.56 (2024-08-26)
- fix android tip error
- fix windows auto launch error
## v0.8.55 (2024-08-25)
- Fix windows tray issues
- Optimize windows logic
- Optimize app logic
- Support windows administrator auto launch
- Support android close vpn
## v0.8.53 (2024-08-15)
- Change flutter version
- Support profiles sort
- Support windows country flags display
- Optimize proxies page and profiles page columns
## v0.8.52 (2024-08-11)
- Update flutter version
- Update version
- Update timeout time
- Update access control page
- Fix bug
## v0.8.51 (2024-08-05)
- Optimize provider page
- Optimize delay test
- Support local backup and recovery
- Fix android tile service issues
## v0.8.49 (2024-07-31)
- Fix linux core build error
- Add proxy-only traffic statistics
- Update core
- Optimize more details
- Merge pull request #140 from txyyh/main
- 添加自建 F-Droid 仓库相关 workflow
- Rename readme fingerprint
- Rename workflow deploy repo name
- Add download guide to README
- Add push release files to fdroid-repo
## v0.8.48 (2024-07-25)
- Optimize proxies page
- Fix ua issues
- Optimize more details
## v0.8.47 (2024-07-22)
- Fix windows build error
## v0.8.46 (2024-07-22)
- Update app icon
- Fix desktop backup error
- Optimize request ua
- Change android icon
- Optimize dashboard
## v0.8.44 (2024-07-18)
- Remove request validate certificate
- Sync core
## v0.8.43 (2024-07-18)
- Fix windows error
## v0.8.42 (2024-07-18)
- Fix setup.dart error
- Fix android system proxy not effective
- Add macos arm64
## v0.8.41 (2024-07-17)
- Optimize proxies page
- Support mouse drag scroll
- Adjust desktop ui
- Revert "Fix android vpn issues"
- This reverts commit 891977408e6938e2acd74e9b9adb959c48c79988.
## v0.8.40 (2024-07-15)
- Fix android vpn issues
- Fix android vpn issues
- Rollback partial modification
## v0.8.39 (2024-07-15)
- Fix the problem that ui can't be synchronized when android vpn is occupied by an external
- Override default socksPort,port
## v0.8.38 (2024-07-14)
- Fix fab issues
## v0.8.37 (2024-07-14)
- Update version
- Fix the problem that vpn cannot be started in some cases
- Fix the problem that geodata url does not take effect
## v0.8.36 (2024-07-13)
- Update ua
- Fix change outbound mode without check ip issues
- Separate android ui and vpn
- Fix url validate issues 2
- Add android hidden from the recent task
- Add geoip file
- Support modify geoData URL
## v0.8.35 (2024-07-07)
- Fix url validate issues
- Fix check ip performance problem
- Optimize resources page
## v0.8.34 (2024-07-04)
- Add ua selector
- Support modify test url
- Optimize android proxy
- Fix the error that async proxy provider could not selected the proxy
## v0.8.33 (2024-07-01)
- Fix android proxy error
- Fix submit error
- Add windows tun
- Optimize android proxy
- Optimize change profile
- Update application ua
- Optimize delay test
## v0.8.32 (2024-06-28)
- Fix android repeated request notification issues
## v0.8.31 (2024-06-28)
- Fix memory overflow issues
## v0.8.30 (2024-06-27)
- Optimize proxies expansion panel 2
- Fix android scan qrcode error
## v0.8.29 (2024-06-27)
- Optimize proxies expansion panel
- Fix text error
## v0.8.28 (2024-06-26)
- Optimize proxy
- Optimize delayed sorting performance
- Add expansion panel proxies page
- Support to adjust the proxy card size
- Support to adjust proxies columns number
- Fix autoRun show issues
- Fix Android 10 issues
- Optimize ip show
## v0.8.26 (2024-06-22)
- Add intranet IP display
- Add connections page
- Add search in connections, requests
- Add keyword search in connections, requests, logs
- Add basic viewing editing capabilities
- Optimize update profile
## v0.8.25 (2024-06-19)
- Update version
- Fix the problem of excessive memory usage in traffic usage.
- Add lightBlue theme color
- Fix start unable to update profile issues
- Fix flashback caused by process
## v0.8.23 (2024-06-16)
- Add build version
- Optimize quick start
- Update system default option
## v0.8.22 (2024-06-16)
- Update build.yml
- Fix android vpn close issues
- Add requests page
- Fix checkUpdate dark mode style error
- Fix quickStart error open app
- Add memory proxies tab index
- Support hidden group
- Optimize logs
- Fix externalController hot load error
## v0.8.21 (2024-06-13)
- Add tcp concurrent switch
- Add system proxy switch
- Add geodata loader switch
- Add external controller switch
- Add auto gc on trim memory
- Fix android notification error
## v0.8.20 (2024-06-12)
- Fix ipv6 error
- Fix android udp direct error
- Add ipv6 switch
- Add access all selected button
- Remove android low version splash
## v0.8.19 (2024-06-10)
- Update version
- Add allowBypass
- Fix Android only pick .text file issues
## v0.8.18 (2024-06-09)
- Fix search issues
## v0.8.17 (2024-06-09)
- Fix LoadBalance, Relay load error
- Fix build.yml4
- Fix build.yml3
- Fix build.yml2
- Fix build.yml
- Add search function at access control
- Fix the issues with the profile add button to cover the edit button
- Adapt LoadBalance and Relay
- Add arm
- Fix android notification icon error
## v0.8.16 (2024-06-08)
- Add one-click update all profiles
- Add expire show
## v0.8.15 (2024-06-06)
- Temp remove tun mode
- Remove macos in workflow
- Change go version
## v0.8.14 (2024-06-06)
- Update Version
- Fix tun unable to open
## v0.8.13 (2024-06-06)
- Optimize delay test2
- Optimize delay test
- Add check ip
- add check ip request
## v0.8.12 (2024-06-06)
- Fix the problem that the download of remote resources failed after GeodataMode was turned on, which caused the
application to flash back.
- Fix edit profile error
- Fix quickStart change proxy error
- Fix core version
## v0.8.10 (2024-06-05)
- Fix core version
## v0.8.9 (2024-06-05)
- Update file_picker
- Add resources page
- Optimize more detail
- Add access selected sorted
- Fix notification duplicate creation issue
- Fix AccessControl click issue
## v0.8.7 (2024-05-31)
- Fix Workflow
- Fix Linux unable to open
- Update README.md 3
- Create LICENSE
- Update README.md 2
- Update README.md
- Optimize workFlow
## v0.8.6 (2024-05-31)
- optimize checkUpdate
## v0.8.5 (2024-05-30)
- Fix submit error
## v0.8.4 (2024-05-30)
- add WebDAV
- add Auto check updates
- Optimize more details
- optimize delayTest
## v0.8.2 (2024-05-15)
- upgrade flutter version
## v0.8.1 (2024-05-15)
- Update kernel
- Add import profile via QR code image
## v0.8.0 (2024-05-11)
- Add compatibility mode and adapt clash scheme.
## v0.7.14 (2024-05-07)
- update Version
- Reconstruction application proxy logic
## v0.7.13 (2024-05-06)
- Fix Tab destroy error
## v0.7.12 (2024-05-06)
- Optimize repeat healthcheck
## v0.7.11 (2024-05-06)
- Optimize Direct mode ui
## v0.7.10 (2024-05-06)
- Optimize Healthcheck
- Remove proxies position animation, improve performance
- Add Telegram Link
- Update healthcheck policy
- New Check URLTest
- Fix the problem of invalid auto-selection
## v0.7.8 (2024-05-05)
- New Async UpdateConfig
- add changeProfileDebounce
- Update Workflow
- Fix ChangeProfile block
- Fix Release Message Error
## v0.7.7 (2024-05-04)
- Update Selector 2
## v0.7.6 (2024-05-04)
- Update Version
- Fix Proxies Select Error
## v0.7.5 (2024-05-03)
- Fix the problem that the proxy group is empty in global mode.
- Fix the problem that the proxy group is empty in global mode.
## v0.7.4 (2024-05-03)
- Add ProxyProvider2
## v0.7.3 (2024-05-03)
- Add ProxyProvider
- Update Version
- Update ProxyGroup Sort
- Fix Android quickStart VpnService some problems
## v0.7.1 (2024-05-01)
- Update version
- Set Android notification low importance
- Fix the issue that VpnService can't be closed correctly in special cases
- Fix the problem that TileService is not destroyed correctly in some cases
- Adjust tab animation defaults
- Add Telegram in README_zh_CN.md
- Add Telegram
## v0.7.0 (2024-04-30)
- update mobile_scanner
- Initial commit
+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
+65 -41
View File
@@ -6,24 +6,17 @@
## FlClash
<p style="text-align: left;">
<img alt="stars" src="https://img.shields.io/github/stars/chen08209/FlClash?style=flat-square&logo=github"/>
<img alt="downloads" src="https://img.shields.io/github/downloads/chen08209/FlClash/total"/>
<a href="LICENSE">
<img alt="license" src="https://img.shields.io/github/license/chen08209/FlClash"/>
</a>
</p>
[![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 on desktop and mobile" src="snapshots/preview.png" width="90%">
</picture>
</p>
## Features
@@ -38,9 +31,38 @@ on Mobile:
✨ Support subscription link, Dark mode
## Contact
## Use
[Telegram](https://t.me/+G-veVtwBOl4wODc1)
### Linux
⚠️ Make sure to install the following dependencies before using them
```bash
sudo apt-get install libayatana-appindicator3-dev
```
### Android
Support the following actions
```bash
com.follow.clash.action.START
com.follow.clash.action.STOP
com.follow.clash.action.TOGGLE
```
## Download
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
### Homebrew
```bash
brew tap chen08209/tap
brew install --cask flclash
```
## Build
@@ -55,50 +77,52 @@ on Mobile:
- android
1. Install `Android SDK` , `Android NDK`
1. Install `Android SDK`, `Android NDK`
2. Set `ANDROID_NDK` environment variables
3. Run Build script
```bash
dart .\setup.dart android
```
- windows
1. You need a windows client
2. Install `Gcc`,`Inno Setup`
2. Set `ANDROID_NDK` environment variable
3. Run build script
```bash
dart .\setup.dart
dart setup.dart android
```
- windows
1. Requires a Windows client
2. Install `GCC`, `Inno Setup`
3. Run build script
```bash
dart setup.dart windows
```
- linux
1. You need a linux client
1. Requires a Linux client
2. Run build script
2. Dependencies are auto-installed by setup script, or manually:
```bash
sudo apt-get install -y libayatana-appindicator3-dev
```
3. Run build script
```bash
dart .\setup.dart
dart setup.dart linux
```
- macOS
1. You need a macOS client
1. Requires a macOS client
2. Run build script
```bash
dart .\setup.dart
dart setup.dart macos
```
## Star
@@ -108,4 +132,4 @@ The easiest way to support developers is to click on the star (⭐) at the top o
<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>
</p>
+52 -25
View File
@@ -6,24 +6,17 @@
## FlClash
<p style="text-align: left;">
<img alt="stars" src="https://img.shields.io/github/stars/chen08209/FlClash?style=flat-square&logo=github"/>
<img alt="downloads" src="https://img.shields.io/github/downloads/chen08209/FlClash/total"/>
<a href="LICENSE">
<img alt="license" src="https://img.shields.io/github/license/chen08209/FlClash"/>
</a>
</p>
[![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>
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 on desktop and mobile" src="snapshots/preview.png" width="90%">
</picture>
</p>
## Features
@@ -38,9 +31,38 @@ on Mobile:
✨ 支持一键导入订阅, 深色模式
## Contact
## Use
[Telegram](https://t.me/+G-veVtwBOl4wODc1)
### Linux
⚠️ 使用前请确保安装以下依赖
```bash
sudo apt-get install libayatana-appindicator3-dev
```
### Android
支持下列操作
```bash
com.follow.clash.action.START
com.follow.clash.action.STOP
com.follow.clash.action.TOGGLE
```
## Download
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
### Homebrew
```bash
brew tap chen08209/tap
brew install --cask flclash
```
## Build
@@ -62,29 +84,34 @@ on Mobile:
3. 运行构建脚本
```bash
dart .\setup.dart android
dart setup.dart android
```
- windows
1. 你需要一个windows客户端
2. 安装 `Gcc`,`Inno Setup`
2. 安装 `GCC`,`Inno Setup`
3. 运行构建脚本
```bash
dart .\setup.dart
dart setup.dart windows
```
- linux
1. 你需要一个linux客户端
2. 运行构建脚本
2. 依赖会由 setup 脚本自动安装,也可以手动安装:
```bash
sudo apt-get install -y libayatana-appindicator3-dev
```
3. 运行构建脚本
```bash
dart .\setup.dart
dart setup.dart linux
```
- macOS
@@ -94,10 +121,10 @@ on Mobile:
2. 运行构建脚本
```bash
dart .\setup.dart
dart setup.dart macos
```
## Star History
## Star
支持开发者的最简单方式是点击页面顶部的星标(⭐)。
@@ -105,4 +132,4 @@ on Mobile:
<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>
</p>
+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
-112
View File
@@ -1,112 +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 "25.1.8937393"
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
signingConfigs {
if (isRelease){
release {
storeFile defStoreFile
storePassword defStorePassword
keyAlias defKeyAlias
keyPassword defKeyPassword
}
}
}
defaultConfig {
applicationId "com.follow.clash"
minSdkVersion 21
targetSdkVersion 34
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
debug {
minifyEnabled false
applicationIdSuffix '.debug'
}
release {
minifyEnabled true
if(isRelease){
signingConfig signingConfigs.release
}else{
signingConfig signingConfigs.debug
}
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
}
tasks.register('copyNativeLibs', Copy) {
delete('src/main/jniLibs')
from('../../libclash/android')
into('src/main/jniLibs')
}
tasks.withType(MergeSourceSetFolders).configureEach {
dependsOn copyNativeLibs
}
flutter {
source '../..'
}
dependencies {
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'com.google.code.gson:gson:2.10'
}
afterEvaluate {
assembleDebug.dependsOn copyNativeLibs
assembleRelease.dependsOn copyNativeLibs
}
+127
View File
@@ -0,0 +1,127 @@
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
id("com.android.application")
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
}
val localProperties = Properties().apply {
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",
)
configure<CrashlyticsExtension> {
nativeSymbolUploadEnabled = hasReleaseSigning
}
}
}
sourceSets {
// Unit tests live under android/tests/ instead of each module's src/test.
getByName("test").java.setSrcDirs(listOf("../tests/app"))
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
flutter {
source = "../.."
}
// The Crashlytics plugin finalizes R8 with the mapping upload but leaves the native symbol upload to the caller.
if (hasReleaseSigning) {
tasks.matching { it.name == "assembleRelease" || it.name == "bundleRelease" }.configureEach {
finalizedBy("uploadCrashlyticsSymbolFileRelease")
}
}
dependencies {
implementation(project(":service"))
implementation(project(":common"))
implementation(project(":core"))
implementation(libs.core.splashscreen)
implementation(libs.gson)
implementation(libs.smali.dexlib2) {
exclude(group = "com.google.guava", module = "guava")
}
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics.ndk)
implementation(libs.firebase.analytics)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
+65
View File
@@ -0,0 +1,65 @@
{
"project_info": {
"project_number": "000000000000",
"project_id": "dev"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "com.follow.clash"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "com.follow.clash.debug"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "com.follow.clash.dev"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
]
}
+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>
+59 -39
View File
@@ -1,48 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
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.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.FOREGROUND_SERVICE_SPECIAL_USE"
tools:ignore="SystemPermissionTypo" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
<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" />
<application
android:name="${applicationName}"
android:name=".FlClashApplication"
android:banner="@mipmap/ic_banner"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:extractNativeLibs="true"
android:label="FlClash">
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" />
<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" />
@@ -53,28 +61,38 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="clash"/>
<data android:scheme="clashmeta"/>
<data android:scheme="flclash"/>
<data android:scheme="clash" />
<data android:scheme="clashmeta" />
<data android:scheme="flclash" />
<data android:host="install-config"/>
<data android:host="install-config" />
</intent-filter>
</activity>
<!-- <meta-data-->
<!-- android:name="io.flutter.embedding.android.EnableImpeller"-->
<!-- android:value="true" />-->
<activity
android:name=".TempActivity"
android:theme="@style/TransparentTheme" />
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="${applicationId}.action.START" />
</intent-filter>
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="${applicationId}.action.STOP" />
</intent-filter>
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="${applicationId}.action.TOGGLE" />
</intent-filter>
</activity>
<service
android:name=".services.FlClashTileService"
android:name=".TileService"
android:exported="true"
android:icon="@drawable/icon"
android:foregroundServiceType="specialUse"
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" />
@@ -83,18 +101,20 @@
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>
<service
android:name=".services.FlClashVpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<receiver
android:name=".ServiceBroadcastReceiver"
android:enabled="true"
android:exported="true"
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
<intent-filter>
<action android:name="android.net.VpnService" />
<action android:name="${applicationId}.intent.action.VPN_START_REQUESTED" />
<action android:name="${applicationId}.intent.action.VPN_REVOKED" />
</intent-filter>
</service>
</receiver>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
</manifest>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -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)
}
}
@@ -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,28 +0,0 @@
package com.follow.clash
import androidx.lifecycle.MutableLiveData
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.embedding.engine.FlutterEngine
import java.util.Date
enum class RunState {
START,
PENDING,
STOP
}
class GlobalState {
companion object {
val runState: MutableLiveData<RunState> = MutableLiveData<RunState>(RunState.STOP)
var runTime: Date? = null
var flutterEngine: FlutterEngine? = null
fun getCurrentTilePlugin(): TilePlugin? =
flutterEngine?.plugins?.get(TilePlugin::class.java) as TilePlugin?
fun getCurrentAppPlugin(): AppPlugin? =
flutterEngine?.plugins?.get(AppPlugin::class.java) as AppPlugin?
}
}
@@ -1,25 +1,22 @@
package com.follow.clash
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ProxyPlugin
import com.follow.clash.plugins.ServicePlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
GlobalState.flutterEngine?.destroy()
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(AppPlugin())
flutterEngine.plugins.add(ProxyPlugin())
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,28 @@
package com.follow.clash
import android.app.Activity
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 kotlinx.coroutines.launch
class QuickActionActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.action) {
QuickAction.START.action -> GlobalState.launch { ServiceState.handleStartAction() }
QuickAction.STOP.action -> GlobalState.launch { ServiceState.handleStopAction() }
QuickAction.TOGGLE.action -> {
ShortcutManagerCompat.reportShortcutUsed(this, SHORTCUT_ID)
GlobalState.launch { ServiceState.handleToggleAction() }
}
}
finish()
}
private companion object {
const val SHORTCUT_ID = "toggle"
}
}
@@ -0,0 +1,53 @@
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 -> {
GlobalState.log("System requested VPN service start")
ServiceState.handleStartAction()
}
BroadcastAction.VPN_REVOKED.action -> {
GlobalState.log("VPN permission revoked")
ServiceState.handleVpnRevokeAction()
}
}
}
companion object {
private const val BROADCAST_TIMEOUT_MILLIS = 9_000L
private val mainHandler = Handler(Looper.getMainLooper())
}
}
@@ -0,0 +1,251 @@
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
private var runTimeMillis = 0L
suspend fun unbind() = lock.withLock {
clearBinding()
}
private fun clearBinding() {
binding?.unbind()
binding = null
}
fun invokeMethod(data: String, callback: (String) -> Unit): Result<Unit> = runCatching {
Core.invokeMethod(data) { result ->
callback(result.orEmpty())
}
}
suspend fun quickSetup(
initParams: String,
setupParams: String,
): Result<String> = runCatching {
suspendCancellableCoroutine { continuation ->
Core.quickSetup(initParams, setupParams) { result ->
continuation.resume(result.orEmpty())
}
}
}
fun setEventListener(callback: ((String?) -> Unit)?): Result<Unit> = runCatching {
Core.updateEventListener(callback)
}
suspend fun start(options: VpnOptions): Long = 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")
clearBinding()
runTimeMillis = 0L
return@withLock runTimeMillis
}
}
val currentBinding = binding ?: return@withLock 0L
val result = currentBinding.useService { service -> service.start() }
if (result.isFailure) {
GlobalState.log("Unable to start background service: ${result.exceptionOrNull()}")
currentBinding.stopIfConnected()
.onFailure { error ->
GlobalState.log("Unable to clean up failed background service start: $error")
}
clearBinding()
runTimeMillis = 0L
return@withLock runTimeMillis
}
if (runTimeMillis == 0L) {
runTimeMillis = System.currentTimeMillis()
}
runTimeMillis
}
suspend fun stop() = lock.withLock {
tearDownServices()
runTimeMillis = 0L
}
private suspend fun tearDownServices() {
binding?.useService { service -> service.stop() }
?.onFailure { error ->
GlobalState.log("Unable to stop background service: $error")
}
clearBinding()
stopServices()
}
// A service the system started itself — always-on VPN, or the sticky restart
// after the process was killed — outlives every binding this process holds, so
// unbinding alone leaves the tunnel up while the app reports it stopped.
private fun stopServices() {
listOf(VpnService::class.intent, ProxyService::class.intent).forEach { intent ->
runCatching { GlobalState.application.stopService(intent) }
.onFailure { error ->
GlobalState.log("Unable to stop ${intent.component?.className}: $error")
}
}
}
suspend fun isVpnServiceActive(): Boolean = lock.withLock {
runTimeMillis != 0L && binding?.component == VpnService::class.intent.component
}
fun getRunTimeMillis(): Long = runTimeMillis
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")
clearBinding()
runTimeMillis = 0L
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,37 @@
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
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 refresh(): Long = machine.refresh()
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,133 @@
package com.follow.clash
import android.net.VpnService
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.TilePlugin
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import io.flutter.embedding.engine.FlutterEngine
import kotlinx.coroutines.CoroutineScope
/**
* The machine owns the arbitration and the transitions; this is only the part that cannot run on a
* plain JVM, so unit tests can drive the state machine with an in-memory implementation.
*/
internal interface ServiceStateHost {
val scope: CoroutineScope
val runTimeMillis: Long
val homeDirPath: String
val sdkInt: Int
fun log(message: String)
fun showToast(message: String)
fun setCrashlytics(enabled: Boolean)
fun updateNotificationParams(params: NotificationParams)
fun loadSharedState(): SharedState
fun isVpnPermissionGranted(): Boolean
fun tile(): TileGateway?
fun app(): AppGateway?
suspend fun quickSetup(initParams: String, setupParams: String): Result<String>
suspend fun startService(options: VpnOptions): Long
suspend fun stopService()
suspend fun isVpnServiceActive(): Boolean
}
internal interface TileGateway {
fun handleStart()
fun handleStop()
}
internal interface AppGateway {
fun requestNotificationPermission(callback: (Boolean) -> Unit)
fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit)
fun cancelVpnPreparation(callback: (Boolean) -> Unit)
}
internal object AndroidServiceStateHost : ServiceStateHost {
@Volatile
private var flutterEngine: FlutterEngine? = null
override val scope: CoroutineScope
get() = GlobalState
override val runTimeMillis: Long
get() = ServiceController.getRunTimeMillis()
override val homeDirPath: String
get() = GlobalState.application.filesDir.path
override val sdkInt: Int
get() = android.os.Build.VERSION.SDK_INT
fun attachFlutterEngine(engine: FlutterEngine) {
flutterEngine = engine
}
fun detachFlutterEngine(engine: FlutterEngine) {
if (flutterEngine === engine) {
flutterEngine = null
}
}
override fun log(message: String) = GlobalState.log(message)
override fun showToast(message: String) = GlobalState.application.showToast(message)
override fun setCrashlytics(enabled: Boolean) = GlobalState.setCrashlytics(enabled)
override fun updateNotificationParams(params: NotificationParams) =
ServiceConfig.updateNotificationParams(params)
override fun loadSharedState(): SharedState = GlobalState.application.sharedState
override fun isVpnPermissionGranted(): Boolean =
VpnService.prepare(GlobalState.application) == null
override fun tile(): TileGateway? = flutterEngine?.plugin<TilePlugin>()?.let { plugin ->
object : TileGateway {
override fun handleStart() = plugin.handleStart()
override fun handleStop() = plugin.handleStop()
}
}
override fun app(): AppGateway? = flutterEngine?.plugin<AppPlugin>()?.let { plugin ->
object : AppGateway {
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
plugin.requestNotificationPermission(callback)
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) =
plugin.prepareVpn(enable, callback)
override fun cancelVpnPreparation(callback: (Boolean) -> Unit) =
plugin.cancelVpnPreparation(callback)
}
}
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> =
ServiceController.quickSetup(initParams, setupParams)
override suspend fun startService(options: VpnOptions): Long =
ServiceController.start(options)
override suspend fun stopService() = ServiceController.stop()
override suspend fun isVpnServiceActive(): Boolean = ServiceController.isVpnServiceActive()
}
@@ -0,0 +1,343 @@
package com.follow.clash
import com.follow.clash.common.RunIntentArbiter
import com.follow.clash.models.SharedState
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import com.google.gson.Gson
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
enum class RunState {
STARTED,
STARTING,
STOPPING,
STOPPED,
}
internal typealias RunRequest = RunIntentArbiter.Token
internal const val MISSING_CONFIG_MESSAGE = "No configuration found."
internal const val INVALID_CONFIG_MESSAGE = "Invalid configuration."
internal const val VPN_PERMISSION_MESSAGE = "VPN permission required."
internal const val START_FAILED_MESSAGE = "Failed to start service."
/**
* Callers request a transition; the newest request always wins. Every step that outlives its own
* suspension point re-checks [isCurrent] before it publishes anything, so a start that was overtaken
* by a stop cannot report itself as started.
*/
internal class ServiceStateMachine(private val host: ServiceStateHost) {
private val transitionLock = Mutex()
private val startPreparationLock = Mutex()
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
private val arbiter = RunIntentArbiter()
@Volatile
private var sharedState = SharedState()
@Volatile
private var pendingVpnPreparation: (() -> Unit)? = null
val runState = mutableRunState.asStateFlow()
private val runTimeMillis: Long
get() = host.runTimeMillis
suspend fun handleToggleAction() {
if (isRunningRequested()) {
handleStopAction()
} else {
handleStartAction()
}
}
suspend fun refresh(): Long = transitionLock.withLock {
val current = runTimeMillis
mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED
current
}
fun captureRequestToken(): RunRequest = arbiter.current()
/**
* Settles the state after the bound service was lost. [token] is the request that was current
* when the loss was observed, so a start that raced ahead of this callback keeps its intent.
*/
suspend fun handleServiceLost(token: RunRequest) = transitionLock.withLock {
if (runTimeMillis != 0L) {
return@withLock
}
if (!arbiter.resetToStopped(token)) {
return@withLock
}
mutableRunState.value = RunState.STOPPED
}
suspend fun handleStartAction() {
if (isRunningRequested()) {
return
}
val tile = host.tile()
if (tile != null) {
tile.handleStart()
return
}
loadPreferencesAndStart()
}
suspend fun handleStopAction() {
if (!isRunningRequested()) {
return
}
val tile = host.tile()
if (tile != null) {
tile.handleStop()
return
}
host.showToast(sharedState.stopTip)
requestStop().await()
}
suspend fun handleVpnRevokeAction() {
if (!host.isVpnServiceActive()) {
return
}
handleStopAction()
}
fun requestStart(): Deferred<Boolean> {
val request = createRequest(running = true)
val result = CompletableDeferred<Boolean>()
val launchRequest: (Boolean) -> Unit = { shouldStart ->
if (!shouldStart) {
fail(request)
result.complete(false)
} else {
host.scope.launch {
result.complete(
runCatching { start(request) }
.onFailure { error ->
host.log("Unable to process service start request: $error")
fail(request)
reconcileStopped()
}
.getOrDefault(false),
)
}
}
}
val app = host.app()
if (app != null) {
app.requestNotificationPermission(launchRequest)
} else {
launchRequest(true)
}
return result
}
fun requestStop(): Deferred<Boolean> {
val request = createRequest(running = false)
val result = CompletableDeferred<Boolean>()
host.scope.launch {
result.complete(
runCatching { stop(request) }
.onFailure { error ->
host.log("Unable to process service stop request: $error")
}
.getOrDefault(false),
)
}
return result
}
fun syncSharedState(state: SharedState) {
sharedState = state
applySharedState()
}
private suspend fun loadPreferencesAndStart() {
sharedState = host.loadSharedState()
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
host.showToast(MISSING_CONFIG_MESSAGE)
return
}
if (setupCore()) {
if (!requestStart().await()) {
host.showToast(START_FAILED_MESSAGE)
}
}
}
private fun applySharedState() {
host.setCrashlytics(sharedState.crashlytics)
host.updateNotificationParams(notificationParams(sharedState))
}
private suspend fun setupCore(): Boolean {
applySharedState()
host.showToast(sharedState.startTip)
return host.quickSetup(
initParams(host.homeDirPath, host.sdkInt),
Gson().toJson(sharedState.setupParams),
).fold(
onSuccess = { message ->
if (message.isEmpty()) {
true
} else {
host.log("Unable to set up core: $message")
showConfigError(message)
false
}
},
onFailure = { error ->
host.log("Unable to set up core: $error")
showConfigError(error.message)
false
},
)
}
private fun showConfigError(message: String?) {
host.showToast(message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE)
}
private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock {
val started = runStart(request)
if (!started) {
reconcileStopped()
}
started
}
private suspend fun runStart(request: RunRequest): Boolean {
if (!isCurrent(request)) {
return false
}
val options = sharedState.vpnOptions
if (options == null) {
fail(request)
return false
}
if (!prepareVpn(options)) {
if (host.app() == null && isCurrent(request)) {
host.showToast(VPN_PERMISSION_MESSAGE)
}
fail(request)
return false
}
if (!isCurrent(request)) {
return false
}
return transitionLock.withLock transition@{
if (!isCurrent(request)) {
return@transition false
}
if (runTimeMillis != 0L && host.isVpnServiceActive() == options.enable) {
mutableRunState.value = RunState.STARTED
return@transition true
}
mutableRunState.value = RunState.STARTING
val startedAtMillis = host.startService(options)
if (startedAtMillis == 0L) {
mutableRunState.value = RunState.STOPPED
fail(request)
return@transition false
}
if (!isCurrent(request)) {
return@transition false
}
mutableRunState.value = RunState.STARTED
true
}
}
private suspend fun reconcileStopped() = transitionLock.withLock {
if (isRunningRequested() || runTimeMillis == 0L) {
return@withLock
}
mutableRunState.value = RunState.STOPPING
host.stopService()
mutableRunState.value = RunState.STOPPED
}
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
if (!isCurrent(request)) {
return@withLock false
}
abandonVpnPreparation()
if (runState.value == RunState.STOPPED && runTimeMillis == 0L) {
return@withLock true
}
mutableRunState.value = RunState.STOPPING
host.stopService()
mutableRunState.value = RunState.STOPPED
isCurrent(request)
}
private suspend fun prepareVpn(options: VpnOptions): Boolean {
val app = host.app()
?: return !options.enable || host.isVpnPermissionGranted()
return suspendCancellableCoroutine { continuation ->
val callback: (Boolean) -> Unit = { granted ->
pendingVpnPreparation = null
if (continuation.isActive) {
continuation.resume(granted)
}
}
pendingVpnPreparation = {
app.cancelVpnPreparation(callback)
callback(false)
}
continuation.invokeOnCancellation {
pendingVpnPreparation = null
app.cancelVpnPreparation(callback)
}
app.prepareVpn(options.enable, callback)
}
}
private fun abandonVpnPreparation() {
val abandon = pendingVpnPreparation ?: return
pendingVpnPreparation = null
abandon()
}
private fun createRequest(running: Boolean): RunRequest = arbiter.request(running)
private fun isRunningRequested(): Boolean = arbiter.isRunningRequested
private fun isCurrent(request: RunRequest): Boolean = arbiter.isCurrent(request)
private fun fail(request: RunRequest) {
arbiter.resetToStopped(request)
}
internal companion object {
/**
* 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,11 +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)
finishAndRemoveTask()
}
}
@@ -0,0 +1,64 @@
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.refresh()
ServiceState.runState.collect(::updateTile)
}
}
}
// Always route through QuickActionActivity: startActivityAndCollapse is
// what closes the shade, so toggling the service in place would leave
// the panel open after a tap.
override fun onClick() {
super.onClick()
openQuickAction()
}
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,37 +0,0 @@
package com.follow.clash.extensions
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.system.OsConstants.IPPROTO_TCP
import android.system.OsConstants.IPPROTO_UDP
import android.util.Base64
import java.net.URL
import androidx.core.graphics.drawable.toBitmap
import com.follow.clash.models.Metadata
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
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.getInetSocketAddress(): InetSocketAddress {
val url = URL("https://$this")
return InetSocketAddress(InetAddress.getByName(url.host), url.port)
}
@@ -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,7 +0,0 @@
package com.follow.clash.models
data class Package(
val packageName: String,
val label: String,
val isSystem:Boolean
)
@@ -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,18 +0,0 @@
package com.follow.clash.models
enum class AccessControlMode {
acceptSelected,
rejectSelected,
}
data class AccessControl(
val mode: AccessControlMode,
val acceptList: List<String>,
val rejectList: List<String>,
)
data class Props(
val accessControl: AccessControl?,
val allowBypass: Boolean?,
val systemProxy: Boolean?,
)
@@ -0,0 +1,23 @@
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 crashlytics: Boolean = true,
val currentProfileName: String = "FlClash",
val stopText: String = "Stop",
val onlyStatisticsProxy: Boolean = false,
val showStopAction: Boolean = true,
val vpnOptions: VpnOptions? = null,
val setupParams: SetupParams? = null,
)
data class SetupParams(
@SerializedName("test-url")
val testUrl: String,
@SerializedName("selected-map")
val selectedMap: Map<String, String>,
)
@@ -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
}
}
@@ -1,216 +1,467 @@
package com.follow.clash.plugins
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.ActivityManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toBitmap
import com.follow.clash.extensions.getBase64
import com.follow.clash.extensions.getInetSocketAddress
import com.follow.clash.extensions.getProtocol
import com.follow.clash.models.Process
import com.follow.clash.models.Package
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.Settings
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.getSystemService
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import com.follow.clash.R
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
import com.follow.clash.common.PendingCallback
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.registerReceiverCompat
import com.follow.clash.getPackageIconPath
import com.follow.clash.packages.PackageResolver
import com.follow.clash.showToast
import com.google.gson.Gson
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.net.InetSocketAddress
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
private var activity: Activity? = null
private var toast: Toast? = null
private var activityBinding: ActivityPluginBinding? = null
private var context: Context? = null
private val activityResultListener =
PluginRegistry.ActivityResultListener(::onActivityResult)
private val permissionsResultListener =
PluginRegistry.RequestPermissionsResultListener(::onRequestPermissionsResultListener)
private lateinit var channel: MethodChannel
private lateinit var scope: CoroutineScope
private var connectivity: ConnectivityManager? = null
private val vpnPrepareCallback = PendingCallback<Boolean>()
private val iconMap = mutableMapOf<String, String?>()
private val requestNotificationCallback = PendingCallback<Boolean>()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext;
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "app")
channel.setMethodCallHandler(this)
private val requestInstalledAppsCallback = PendingCallback<Boolean>()
private var isRequestingNotificationPermission = false
private val gson = Gson()
private val packageResolver by lazy {
PackageResolver(
GlobalState.application.packageManager,
GlobalState.application.packageName,
)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
private var packageChangeContext: Context? = null
private fun tip(message: String?) {
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
}
val addedByUpdate = intent.action == Intent.ACTION_PACKAGE_ADDED &&
intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
if (addedByUpdate) {
return
}
packageResolver.invalidate()
channel.invokeMethod("packagesChanged", null)
}
toast = Toast.makeText(context, message, Toast.LENGTH_SHORT)
toast!!.show()
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
private var skipNotificationPermissionRequest = false
private val mainHandler = Handler(Looper.getMainLooper())
/**
* The permission and consent hops below touch the Activity — starting an
* activity for result, raising a permission prompt — and read the state that
* tracks whether one is already up. Their callers are coroutines on
* [Dispatchers.Default], while the answers come back on the main thread
* through the ActivityAware listeners, so main is the one thread both ends
* can agree on. A plain main-looper post rather than the plugin scope: the
* scope is cancelled when the engine detaches, and a request dropped there
* would leave its caller waiting for a callback that can no longer run.
*/
private fun onMainThread(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
mainHandler.post(block)
}
}
override fun onMethodCall(call: MethodCall, rawResult: Result) {
val result = MainThreadResult(rawResult)
when (call.method) {
"moveTaskToBack" -> {
activity?.moveTaskToBack(true)
result.success(true);
result.success(true)
}
"getPackages" -> {
scope.launch {
result.success(getPackages())
"updateExcludeFromRecents" -> {
val value = call.argument<Boolean>("value")
updateExcludeFromRecents(value)
result.success(true)
}
"initShortcuts" -> {
val label = call.arguments as? String
if (label == null) {
result.error("INVALID_ARGUMENT", "Shortcut label must be a string", null)
} else {
initShortcuts(label)
result.success(true)
}
}
"getPackages" -> reply(result) {
gson.toJson(packageResolver.installedPackages)
}
"getChinaPackageNames" -> reply(result) {
gson.toJson(packageResolver.getChinaPackageNames())
}
"isInstalledAppsPermissionGranted" -> reply(result) {
packageResolver.hasInstalledAppsPermission()
}
"requestInstalledAppsPermission" -> {
requestInstalledAppsPermission { granted -> result.success(granted) }
}
"getPackageIcon" -> {
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
}
}
}
"resolverProcess" -> {
val data = call.argument<String>("data")
val process =
if (data != null) Gson().fromJson(
data,
Process::class.java
) else null
val metadata = process?.metadata
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
}
if (context == null) {
result.success(null)
return@withContext
}
if (connectivity == null) {
connectivity = context!!.getSystemService<ConnectivityManager>()
}
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())
}
}
handleGetPackageIcon(call, result)
}
"tip" -> {
val message = call.argument<String>("message")
tip(message)
GlobalState.application.showToast(message)
result.success(true)
}
"isBatteryOptimizationDisabled" -> {
result.success(isBatteryOptimizationDisabled())
}
"openBatteryOptimizationSettings" -> {
result.success(openBatteryOptimizationSettings())
}
"openAppSettings" -> {
result.success(openAppSettings())
}
"didCrashOnPreviousExecution" -> reply(result) {
GlobalState.didCrashOnPreviousExecution()
}
"getLastExitInfo" -> reply(result) {
GlobalState.lastExitInfo()
}
else -> {
result.notImplemented();
result.notImplemented()
}
}
}
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
private fun handleGetPackageIcon(call: MethodCall, result: Result) = reply(result) {
val packageName = call.argument<String>("packageName") ?: ""
GlobalState.application.packageManager.getPackageIconPath(packageName)
}
// A throw inside the plugin scope has no handler and ends the process;
// the Dart caller gets a PlatformException to handle instead.
private fun reply(result: Result, block: suspend () -> Any?) {
scope.launch(Dispatchers.IO) {
try {
result.success(block())
} catch (error: Exception) {
GlobalState.log("Platform call failed: $error")
result.error("PLATFORM_ERROR", error.toString(), null)
}
}
return iconMap[packageName]
}
private suspend fun getPackages(): String {
return withContext(Dispatchers.Default) {
val packageManager = context?.packageManager
val packages: List<Package>? =
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
)
}
Gson().toJson(packages)
}
}
fun requestGc() {
channel.invokeMethod("gc", null)
private fun initShortcuts(label: String) {
val shortcut = with(ShortcutInfoCompat.Builder(GlobalState.application, "toggle")) {
setShortLabel(label)
setIcon(
IconCompat.createWithResource(
GlobalState.application,
R.mipmap.ic_launcher_round,
),
)
setIntent(QuickAction.TOGGLE.quickIntent)
build()
}
ShortcutManagerCompat.setDynamicShortcuts(
GlobalState.application,
listOf(shortcut),
)
}
private fun isBatteryOptimizationDisabled(): Boolean {
val powerManager = getSystemService(GlobalState.application, PowerManager::class.java)
return powerManager?.isIgnoringBatteryOptimizations(GlobalState.application.packageName)
?: false
}
@SuppressLint("BatteryLife")
private fun openBatteryOptimizationSettings(): Boolean {
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
val activity = activity ?: return false
return try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = "package:${GlobalState.application.packageName}".toUri()
}
activity.startActivity(intent)
true
} catch (_: Exception) {
false
}
}
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(GlobalState.application, ActivityManager::class.java)
val task = am?.appTasks?.firstOrNull {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
it.taskInfo.taskId == activity?.taskId
} else {
it.taskInfo.id == activity?.taskId
}
}
task?.setExcludeFromRecents(value ?: false)
}
fun requestNotificationPermission(callback: (Boolean) -> Unit) = onMainThread {
requestNotificationCallback.replace(callback, supersededValue = false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = ContextCompat.checkSelfPermission(
GlobalState.application,
Manifest.permission.POST_NOTIFICATIONS,
)
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
invokeRequestNotificationCallback(true)
return@onMainThread
}
if (isRequestingNotificationPermission) {
return@onMainThread
}
isRequestingNotificationPermission = true
activity?.let {
ActivityCompat.requestPermissions(
it,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE,
)
} ?: invokeRequestNotificationCallback(true)
return@onMainThread
}
invokeRequestNotificationCallback(true)
}
private fun invokeRequestNotificationCallback(shouldStart: Boolean) {
isRequestingNotificationPermission = false
requestNotificationCallback.resolve(shouldStart)
}
private fun requestInstalledAppsPermission(callback: (Boolean) -> Unit) = onMainThread {
requestInstalledAppsCallback.replace(callback, supersededValue = false)
val activity = activity
if (packageResolver.hasInstalledAppsPermission()) {
invokeRequestInstalledAppsCallback(true)
return@onMainThread
}
if (activity == null) {
invokeRequestInstalledAppsCallback(false)
return@onMainThread
}
ActivityCompat.requestPermissions(
activity,
arrayOf(PackageResolver.GET_INSTALLED_APPS),
INSTALLED_APPS_PERMISSION_REQUEST_CODE,
)
}
private fun invokeRequestInstalledAppsCallback(granted: Boolean) {
if (granted) {
packageResolver.invalidate()
}
requestInstalledAppsCallback.resolve(granted)
}
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) = onMainThread {
vpnPrepareCallback.replace(callback, supersededValue = false)
if (!needPrepare) {
invokeVpnPrepareCallback(true)
return@onMainThread
}
val intent = VpnService.prepare(GlobalState.application)
if (intent != null) {
val activity = activity
if (activity == null) {
invokeVpnPrepareCallback(false)
} else {
@Suppress("DEPRECATION")
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
}
return@onMainThread
}
invokeVpnPrepareCallback(true)
}
// Posted rather than run where the cancellation lands, so it stays ordered
// behind the prepareVpn that installed the callback it is cancelling.
fun cancelVpnPreparation(callback: (Boolean) -> Unit) = onMainThread {
vpnPrepareCallback.cancel(callback)
}
private fun invokeVpnPrepareCallback(granted: Boolean) {
vpnPrepareCallback.resolve(granted)
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
channel =
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
channel.setMethodCallHandler(this)
watchPackageChanges(flutterPluginBinding.applicationContext)
}
private fun watchPackageChanges(context: Context) {
val filter = IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
addDataScheme("package")
}
context.registerReceiverCompat(packageChangeReceiver, filter)
packageChangeContext = context
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
packageChangeContext?.unregisterReceiver(packageChangeReceiver)
packageChangeContext = null
channel.setMethodCallHandler(null)
scope.cancel()
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
invokeRequestInstalledAppsCallback(false)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity;
scope = CoroutineScope(Dispatchers.Default)
attachToActivity(binding)
}
private fun attachToActivity(binding: ActivityPluginBinding) {
detachFromActivity()
activityBinding = binding
activity = binding.activity
binding.addActivityResultListener(activityResultListener)
binding.addRequestPermissionsResultListener(permissionsResultListener)
}
private fun detachFromActivity() {
activity = null
val binding = activityBinding ?: return
activityBinding = null
binding.removeActivityResultListener(activityResultListener)
binding.removeRequestPermissionsResultListener(permissionsResultListener)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null;
detachFromActivity()
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity;
attachToActivity(binding)
}
override fun onDetachedFromActivity() {
channel.invokeMethod("exit", null)
scope.cancel()
activity = null;
detachFromActivity()
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
invokeRequestInstalledAppsCallback(false)
}
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode != VPN_PERMISSION_REQUEST_CODE) {
return false
}
invokeVpnPrepareCallback(resultCode == Activity.RESULT_OK)
return true
}
private fun onRequestPermissionsResultListener(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
): Boolean = when (requestCode) {
NOTIFICATION_PERMISSION_REQUEST_CODE -> {
skipNotificationPermissionRequest = true
invokeRequestNotificationCallback(true)
true
}
INSTALLED_APPS_PERMISSION_REQUEST_CODE -> {
invokeRequestInstalledAppsCallback(
grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED,
)
true
}
else -> false
}
private companion object {
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
const val INSTALLED_APPS_PERMISSION_REQUEST_CODE = 1003
}
}
@@ -0,0 +1,37 @@
package com.follow.clash.plugins
import android.os.Handler
import android.os.Looper
import io.flutter.plugin.common.MethodChannel
/**
* Delivers a [MethodChannel.Result] on the main thread, which is where Flutter
* requires it, from handlers that finish on a background dispatcher.
*
* The hop goes through the main looper rather than the plugin's coroutine scope:
* that scope is cancelled when the engine detaches, and a reply dropped by a
* cancelled scope leaves the Dart future waiting forever. A reply posted after
* the engine is gone is the messenger's problem to ignore, and it does.
*/
internal class MainThreadResult(
private val delegate: MethodChannel.Result,
) : MethodChannel.Result {
override fun success(result: Any?) = post { delegate.success(result) }
override fun error(code: String, message: String?, details: Any?) =
post { delegate.error(code, message, details) }
override fun notImplemented() = post { delegate.notImplemented() }
private fun post(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
mainHandler.post(block)
}
}
private companion object {
val mainHandler = Handler(Looper.getMainLooper())
}
}
@@ -1,227 +0,0 @@
package com.follow.clash.plugins
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.os.IBinder
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.follow.clash.GlobalState
import com.follow.clash.RunState
import com.follow.clash.models.Props
import com.follow.clash.services.FlClashVpnService
import com.google.gson.Gson
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.Date
class ProxyPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
val VPN_PERMISSION_REQUEST_CODE = 1001
val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
private lateinit var flutterMethodChannel: MethodChannel
private var activity: Activity? = null
private var context: Context? = null
private var flClashVpnService: FlClashVpnService? = null
private var isBound = false
private var port: Int? = null
private var props: Props? = null
private lateinit var title: String
private lateinit var content: String
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as FlClashVpnService.LocalBinder
flClashVpnService = binder.getService()
port?.let { startVpn(it) }
isBound = true
}
override fun onServiceDisconnected(arg0: ComponentName) {
flClashVpnService = null
isBound = false
}
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext
flutterMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "proxy")
flutterMethodChannel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
flutterMethodChannel.setMethodCallHandler(null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
"StartProxy" -> {
port = call.argument<Int>("port")
val args = call.argument<String>("args")
props =
if (args != null) Gson().fromJson(args, Props::class.java) else null
handleStartVpn()
result.success(true)
}
"StopProxy" -> {
stopVpn()
result.success(true)
}
"SetProtect" -> {
val fd = call.argument<Int>("fd")
if (fd != null) {
flClashVpnService?.protect(fd)
result.success(true)
} else {
result.success(false)
}
}
"GetRunTimeStamp" -> {
result.success(GlobalState.runTime?.time)
}
"startForeground" -> {
title = call.argument<String>("title") as String
content = call.argument<String>("content") as String
requestNotificationsPermission()
result.success(true)
}
else -> {
result.notImplemented()
}
}
private fun handleStartVpn() {
val intent = VpnService.prepare(context)
if (intent != null) {
activity?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
} else {
bindService()
}
}
private fun startVpn(port: Int) {
if (GlobalState.runState.value == RunState.START) return;
flClashVpnService?.start(port, props)
GlobalState.runState.value = RunState.START
GlobalState.runTime = Date()
startAfter()
}
private fun stopVpn() {
if (GlobalState.runState.value == RunState.STOP) return
flClashVpnService?.stop()
unbindService()
GlobalState.runState.value = RunState.STOP;
GlobalState.runTime = null;
}
@SuppressLint("ForegroundServiceType")
private fun startForeground() {
if (GlobalState.runState.value != RunState.START) return
flClashVpnService?.startForeground(title, content)
}
private fun requestNotificationsPermission() {
if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
val permission = context?.let {
ContextCompat.checkSelfPermission(
it,
Manifest.permission.POST_NOTIFICATIONS
)
}
if (permission == PackageManager.PERMISSION_GRANTED) {
startForeground()
} else {
activity?.let {
ActivityCompat.requestPermissions(
it,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE
)
}
}
} else {
startForeground()
}
}
private fun startAfter() {
flutterMethodChannel.invokeMethod("startAfter", flClashVpnService?.fd)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addActivityResultListener(::onActivityResult)
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
}
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
if (resultCode == FlutterActivity.RESULT_OK) {
bindService()
} else {
stopVpn()
}
}
return true;
}
private fun onRequestPermissionsResultListener(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
): Boolean {
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startForeground()
}
}
return true;
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null;
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
}
override fun onDetachedFromActivity() {
stopVpn()
activity = null
}
private fun bindService() {
val intent = Intent(context, FlClashVpnService::class.java)
context?.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
private fun unbindService() {
if (isBound) {
context?.unbindService(connection)
isBound = false
}
}
}
@@ -0,0 +1,115 @@
package com.follow.clash.plugins
import com.follow.clash.ServiceController
import com.follow.clash.ServiceState
import com.follow.clash.common.Components
import com.follow.clash.models.SharedState
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
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 channel: MethodChannel
private lateinit var scope: CoroutineScope
private val gson = Gson()
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
channel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/service")
channel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
scope.cancel()
ServiceController.setEventListener(null)
}
override fun onMethodCall(call: MethodCall, rawResult: MethodChannel.Result) {
// Most handlers below reply from a scope worker on Dispatchers.Default,
// but a MethodChannel.Result has to be answered on the platform thread.
// Wrapping once here covers every branch, including notImplemented.
val result = MainThreadResult(rawResult)
when (call.method) {
"init" -> initialize(result)
"shutdown" -> shutdown(result)
"invokeMethod" -> invokeMethod(call, result)
"getRunTime" -> getRunTime(result)
"syncState" -> syncState(call, result)
"start" -> start(result)
"stop" -> stop(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) {
scope.launch {
ServiceController.unbind()
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
}
scope.launch {
ServiceController.invokeMethod(data) { response ->
result.success(response)
}.onFailure { error ->
result.error("CORE_ERROR", error.message, null)
}
}
}
private fun getRunTime(result: MethodChannel.Result) {
scope.launch {
result.success(ServiceState.refresh())
}
}
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(result: MethodChannel.Result) {
ServiceState.requestStart()
result.success(true)
}
private fun stop(result: MethodChannel.Result) {
ServiceState.requestStop()
result.success(true)
}
private fun sendEvent(value: String?) {
scope.launch(Dispatchers.Main) {
channel.invokeMethod("event", value)
}
}
}
@@ -1,37 +1,33 @@
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() }
channel.invokeMethodOnMainThread("stop")
}
private fun handleDetached() {
channel.invokeMethod("detached", null)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
result.notImplemented()
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {}
}
}
@@ -1,109 +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.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.annotation.RequiresApi
import androidx.lifecycle.Observer
import com.follow.clash.GlobalState
import com.follow.clash.RunState
import com.follow.clash.TempActivity
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ProxyPlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
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)
}
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)
}
}
private var flutterEngine: FlutterEngine? = null;
private fun initFlutterEngine() {
flutterEngine = FlutterEngine(this)
flutterEngine?.plugins?.add(ProxyPlugin())
flutterEngine?.plugins?.add(TilePlugin())
flutterEngine?.plugins?.add(AppPlugin())
GlobalState.flutterEngine = flutterEngine
if (flutterEngine?.dartExecutor?.isExecutingDart != true) {
val vpnService = DartExecutor.DartEntrypoint(
FlutterInjector.instance().flutterLoader().findAppBundlePath(),
"vpnService"
)
flutterEngine?.dartExecutor?.executeDartEntrypoint(vpnService)
}
}
override fun onClick() {
super.onClick()
activityTransfer()
val currentTilePlugin = GlobalState.getCurrentTilePlugin()
if (GlobalState.runState.value == RunState.STOP) {
GlobalState.runState.value = RunState.PENDING
if(currentTilePlugin == null){
initFlutterEngine()
}else{
currentTilePlugin.handleStart()
}
} else if(GlobalState.runState.value == RunState.START){
GlobalState.runState.value = RunState.PENDING
currentTilePlugin?.handleStop()
}
}
override fun onDestroy() {
GlobalState.runState.removeObserver(observer)
super.onDestroy()
}
}
@@ -1,186 +0,0 @@
package com.follow.clash.services
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.ProxyInfo
import android.net.VpnService
import android.os.Binder
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.follow.clash.GlobalState
import com.follow.clash.MainActivity
import com.follow.clash.R
import com.follow.clash.models.AccessControlMode
import com.follow.clash.models.Props
class FlClashVpnService : VpnService() {
private val CHANNEL = "FlClash"
var fd: Int? = null
private val notificationId: Int = 1
private val passList = listOf(
"*zhihu.com",
"*zhimg.com",
"*jd.com",
"100ime-iat-api.xfyun.cn",
"*360buyimg.com",
"localhost",
"*.local",
"127.*",
"10.*",
"172.16.*",
"172.17.*",
"172.18.*",
"172.19.*",
"172.2*",
"172.30.*",
"172.31.*",
"192.168.*"
)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
fun start(port: Int, props: Props?) {
fd = with(Builder()) {
addAddress("172.16.0.1", 30)
setMtu(9000)
addRoute("0.0.0.0", 0)
props?.accessControl?.let { accessControl ->
when (accessControl.mode) {
AccessControlMode.acceptSelected -> {
(accessControl.acceptList + packageName).forEach {
addAllowedApplication(it)
}
}
AccessControlMode.rejectSelected -> {
(accessControl.rejectList - packageName).forEach {
addDisallowedApplication(it)
}
}
}
}
addDnsServer("172.16.0.2")
setSession("FlClash")
setBlocking(false)
if (Build.VERSION.SDK_INT >= 29) {
setMetered(false)
}
if (props?.allowBypass == true) {
allowBypass()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && props?.systemProxy == true) {
setHttpProxy(
ProxyInfo.buildDirectProxy(
"127.0.0.1",
port,
passList
)
)
}
establish()?.detachFd()
}
}
fun stop() {
stopSelf()
stopForeground()
}
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(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 onTrimMemory(level: Int) {
super.onTrimMemory(level)
GlobalState.getCurrentAppPlugin()?.requestGc()
}
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.Q) {
startForeground(notificationId, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(notificationId, notification)
}
}
private fun stopForeground() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): FlClashVpnService = this@FlClashVpnService
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun onUnbind(intent: Intent?): Boolean {
GlobalState.getCurrentTilePlugin()?.handleStop();
return super.onUnbind(intent)
}
override fun onDestroy() {
stop()
super.onDestroy()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +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"/>
</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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

@@ -1,6 +1,6 @@
<?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="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,6 +1,6 @@
<?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="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

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