docs(agents): document custom profiles and setting changes

This commit is contained in:
chen08209
2026-10-08 17:58:43 +08:00
parent 491fae10f5
commit 8d93268de5
16 changed files with 830 additions and 230 deletions
+409 -119
View File
@@ -6,9 +6,12 @@ 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.
- `gomobile bind` builds `core/mobile` into an AAR: a native library per ABI, shipped as `libclash.so`, and the Java
classes gobind generates in `com.follow.clash.core.mobile`. The `:core` Gradle module builds it and takes in its
classes and libraries (see Android Core Build).
- The Android `:core` module owns the binding: `Core.kt` is the only caller of the generated `Mobile` class. Flutter
crosses the `${packageName}/service` MethodChannel through `lib/plugins/service.dart` and Android's `ServicePlugin`
rather than calling the Core 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.
@@ -17,12 +20,14 @@ 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.
on Windows, served by an `IpcServer` handle. 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/transport.dart` binds an `IpcServer` and turns its events into ready, connected, disconnected, and
failed transport events plus a frame stream. A connection that ends on an I/O error is a disconnect carrying that
error; failed means the server itself is gone and is terminal for that transport. 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.
@@ -34,46 +39,71 @@ Desktop core mode:
Key Go core files:
- `core/hub.go`: handler functions.
- `core/method.go`: MethodChannel-style method-call dispatch and response envelopes.
- `core/protocol.go`: method names, event types, and the parameter and result shapes shared with Dart.
- `core/method.go`: MethodChannel-style method-call dispatch, the handler table, and response envelopes.
- `core/lifecycle.go`, `config.go`, `proxy.go`, `delay.go`, `provider.go`, `geo.go`, `connection.go`, `log.go`: the
handler functions, one file per area. `core/hooks.go` wires mihomo's hooks to them.
- `core/message.go`: non-blocking state/priority/bulk event queues and bounded message batching.
- `core/lib.go`: CGO exports.
- `core/lib.go`: the Android library API: TUN callbacks, the event listener, and the entry points `core/mobile` binds.
- `core/mobile/`: the package gomobile binds. gobind generates Java for its exported API, so it speaks only `int32`,
`bool`, `string`, `[]byte` and its own interfaces; a Go `int` would become a Java `long`.
- `core/cmd/core/`: the desktop entry point. `core/` itself is the library package `core`, because gomobile cannot bind
a `main` package.
- `core/server.go`: desktop socket/named-pipe client and framed message forwarding.
## DNS Override
## DNS, NTP and Sniffer Overrides
`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 URL or file profile brings its own sections; a custom profile brings none. The app's DNS page
(`PatchClashConfig.dns` with `dnsOverrideKeys`) is written over the DNS of every URL and file profile, and offers only
`DnsOverrideKey.normalProfileKeys`: keys that name nothing of a profile's own, because a subscription's groups and rule
sets change with every update. A custom profile keeps all three sections in `Profile.overrides`, a `ProfileOverrides`
stored as JSON in the `profiles.overrides` column, with every key available, and NTP and sniffer exist nowhere else.
`setupState` resolves `SetupState.overrides` by profile type, intersecting the app-wide keys with the normal set, and
`_makeRealProfileTask` merges whatever it is handed; there is no on/off toggle, a picked key is the override.
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.
Each `*OverrideKey` names one Clash key, with `parent.field` paths for nested maps, and covers every yaml key of the
matching mihomo raw struct; `test/models/{dns,ntp,sniffer}_override_test.dart` read `core/Clash.Meta/config/config.go`
and fail when a core bump adds or drops one. `overrideJson` builds the fragment in model order and skips a key whose
value is empty, so a key left without a value writes nothing: the core refuses an empty `nameserver` outright.
`mergeDnsOverride` merges one level deep so a partial fallback filter keeps the profile's other filter keys; NTP spreads
over the profile's flat `ntp` map. For a profile whose `dns.enable` is not true, the task first fills `baselineDns` for
`baselineDnsOverrideKeys` (`enable`, `enhanced-mode: fake-ip`, `nameserver`), the one thing the core is never without:
with DNS off, mihomo answers every TUN-hijacked query with an error. The baseline stays apart from the model defaults,
which carry no preset of the app.
## NTP Override
An added key starts from the spec's `defaults` through its `OverrideField.reset`, and those defaults are mihomo's: a key
starts at the value mihomo parses without it, presets included, such as the nameservers, fake-IP range and filter, and
NTP server of `DefaultRawConfig`. A key mihomo leaves unset, such as `listen` or a sniff protocol's ports, starts empty
and writes nothing until the user fills it in, and so does the fallback filter's GeoIP code, whose value is left for the
user to choose. mihomo sniffs a protocol listed without ports on that protocol's standard port, which the page shows as
the default. The tests above pin the defaults against `DefaultRawConfig`. A spec that mixes in `OverrideQuickEdit` gets
quick edit, which opens `overrideYaml` in an `EditorPage` and rebuilds the key set from the document through
`applyOverrideYaml`, rejecting keys outside the section and values the model cannot hold.
`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.
mihomo upper-cases sniff protocol names and ignores the deprecated `sniffing` list once `sniff` holds anything, so
`mergeSnifferOverride` drops the profile's entry for an overridden protocol in any case and first carries `sniffing`
into `sniff`. A `rule-set:` key of a custom profile's DNS policy counts as a use of that rule provider, so
`_resolveInjectedProviders` injects it like one a rule names. Safe mode forces `write-to-system` off on whatever `ntp`
section reaches the core, because setting the system clock is a host change; `loadConfig` in the Go wrapper also drops
it on Android, where the seccomp policy kills the process for `settimeofday`.
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`.
A custom profile's TUN page is the exception to covering the raw struct. The network settings own `tun` through
`PatchClashConfig.tun`, spread last over the profile's section and patched again on every `updateConfig`, so
`TunOverrideKey` offers only a few keys outside it that work in FlClash's TUN: `disable-icmp-forwarding`, and
`exclude-interface`, which only Linux routing reads. `test/models/tun_override_test.dart` fails if a key comes to
overlap `Tun`. Android starts its VPN from `core/tun` without the section, so TUN is listed on desktop only.
Being a part of the section, the page has no quick edit; see Quick Edit.
A custom profile's overview keeps tiles for rules, providers and DNS, which every custom profile runs on, and puts the
dialer proxies, NTP, sniffer and TUN behind a single "more" tile, which opens a page listing them with their states.
Another rarely changed section joins that list rather than taking a tile of its own.
Every page is an `OverrideView` over an `OverrideSpec` from `lib/views/config/override.dart`. The spec supplies the
section's keys, labels and fields; its `OverrideTarget` is where the model and keys live, `PatchOverrideTarget` for the
app-wide DNS and `ProfileOverrideTarget` for a profile. Another overridable section is a spec, not a copy of a page.
A config from a release that still had the `overrideDns` toggle keeps, through `Config.fromJson`, the normal keys of
an on toggle and none of an off one. That release had no custom profiles, so its app-wide NTP override is dropped.
## Listener Exposure
@@ -145,6 +175,27 @@ subscription's value cannot survive into the generated profile, `follow` leaves
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.
### Network Settings
Every `Tun` field is written over the profile's `tun` section and hot-applied through `tunSchema`, and the other keys of
that section keep the profile's value; `test/models/tun_test.dart` keeps the model, `RawTun` and `tunSchema` in step.
Beyond what FlClash always managed, `Tun` carries only the keys worth tuning: `mtu`, the mips stack's
`congestion-controller`, and `strict-route` where sing-tun enforces it (Linux and Windows).
Routing is the same on every platform: `route-address` (empty routes everything), `route-exclude-address`, and
`NetworkProps.bypassPrivateRoute`, which adds `privateRouteAddress` to the exclusions in `Tun.getRealTun`. Desktop hands
both lists to sing-tun. VpnService can only add routes before Android 13, so `Tun.vpnRouteAddress` cuts the exclusions
out of the routes with `subtractCidrs` and lists every family it routes; `VpnService.addRoutes` routes everything only
for an empty list. A config saved with the old `routeMode` keeps its choice: `bypassPrivate`, which ignored the route
list, loads with the flag on and the list cleared, and `config` with the flag off. Others were left out on purpose: no stack in the vendored sing-tun reads `endpoint-independent-nat`, the
default mips stack ignores `udp-timeout`, and the rest are platform plumbing few users need. Android builds its TUN from
the VpnService descriptor in `core/tun/tun.go`, so only MTU and the congestion controller reach it, through `VpnOptions`
and the `options` argument of `startTUN`; the VpnService builder takes the same MTU.
The TUN fields start at mihomo's own defaults, and a saved zero MTU or empty congestion controller, which mihomo reads
as those defaults, decodes to them. The outbound rows are plain settings written over every profile, and changing one
re-applies the profile; keep-alive is forced off on Android, and only Linux honours `routing-mark`.
## Lifecycle Ownership And Convergence
### Shared Flutter Layer
@@ -173,6 +224,12 @@ The coordinator is idempotent, continues later cleanup steps after an earlier er
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.
### Applying Setting Changes
A changed setting is patched live through `UpdateParams`, re-applied with the profile, or, for the Android VPN, left
to a restart the user confirms. [setting-changes.md](setting-changes.md) says which path each setting takes and how to
choose one for a new setting.
### Desktop Lifecycle
`DesktopCoreLifecycle` is a latest-desired-intent reconciler, not a queue that blindly executes every request:
@@ -202,27 +259,55 @@ Android deliberately keeps Flutter requests optimistic and the native layer auth
- `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.
- `ServiceState` owns the latest `RunRequest` and the shared configuration. Identity checks discard obsolete work.
`startPreparationLock` serializes permission/setup preparation and `transitionLock` serializes actual service
transitions.
- `RunState` is derived, never assigned by a step: `ServiceStateMachine.publish` computes it from the transition in
flight, the latest intent, and `ServiceController.runtime`. A service that is up under a stop intent reads `STOPPING`, a
start still waiting on a permission reads `STOPPED`, and `STARTED` needs both a running intent and a runtime. The tile
and `getRunTime` both read this one value, so they cannot disagree with the service.
- `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.
binds with a five-second connection timeout, and invokes `ManagedService.start()`/`stop()` off the main thread. The
binding and `runtime` (which service runs, and since when) are set and cleared together, by a stop, a failed start, or
a lost connection and by nothing else. Flutter's `shutdown` only clears the Core event listener: Android keeps an
established `VpnService` alive through its own connection, so a binding dropped on exit left a tunnel that was
reported as running but that neither a stop nor a revoke could reach.
- The Core listens exactly while `runtime` is set: a successful start ends in `Core.startListener()` and every path
that clears `runtime` calls `Core.stopListener()`. Both return at once when the Core is already there, which is the
usual case, since `startTUN`/`stopTun` and Flutter's own `startListener`/`stopListener` got there first. They matter
when nothing else does: a proxy-only run stopped from the tile used to keep the mixed port open with nothing
reported as running. A start closes after itself the same way: Flutter's `startListener` and the native `quickSetup`
both open the ports before any service exists, so a start that ends without a `runtime`, refused or cancelled, stops
the empty service once more, without passing through `STOPPING`. Flutter may be gone by then and never see the
`stopped` report.
- A start that does not bring the service up gives its intent back and takes down whatever it left running, unless a
newer request has claimed the service. A failed start therefore always ends in `STOPPED`, whichever step refused it.
The one start that mints no request is a repeat on a settled run of the same kind, which Flutter sends every time it
reattaches: a request refused at a permission prompt there would stop a service that was running fine.
- 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.
- With a Flutter engine attached, `ServiceState.handleStartAction()` forwards through `TilePlugin` to `TileManager`,
because only Flutter can apply the current profile. Without Flutter, native code claims the intent, restores
`SharedState` from preferences, runs `quickSetup`, checks VPN permission, and runs the start; claiming first is what
makes a second action during the setup toggle that start off instead of racing a second setup against it.
- A stop never needs Flutter. `handleStopAction()` stops the service natively whether or not an engine is attached, so
the tile, the notification action and a revoke work even when Flutter's own idea of the state is wrong.
- Whenever the run ends without Flutter having asked for it (a start that failed, a native stop, a revoke, a lost
service), `ServicePlugin` sends `stopped` with the revision of the last `start`/`stop` command it received.
`Service` in `lib/plugins/service.dart` drops a report older than its newest command, or one that arrives while its
last command was a stop (on-demand suspension stops the service while `isStartProvider` stays true), and
`AndroidManager` answers the rest with `setRunning(false)`. The report is read on the platform thread, where the
commands arrive, so the revision and the intent it carries describe the same instant.
- 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.
- `VpnService.onRevoke()` stops TUN/modules first, then sends `VPN_REVOKED`; the receiver only requests a stop while
`ServiceController.runtime` is a VPN run.
- `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.
@@ -257,6 +342,14 @@ a resolver and are not recorded. Queries from apps arrive with the `DNSContext`
report `app`; other initiators come from `resolver.WithInitiator` at the call site (rule matching, DIRECT, proxy
server dialing), and unlabeled queries report `other`.
Dialer-loop events come from the fork's `component/proxydialer/patch.go`. mihomo follows a `dialer-proxy` that leads
back to its own proxy until the stack overflows, so a dial that asks the same `dialer-proxy` for an address it is
already dialing through it fails with `ErrDialerLoop`. `core/hooks.go` reports each looping name at most once every
three seconds as a priority event, which the app shows as an error notice whether or not logs are captured. The custom
profile pages flag only the loops FlClash can follow on its own, through a group's own list or an include-all that no
filter narrows; what filters and providers pick, including provider proxies that carry their own `dialer-proxy`, is
left to this guard.
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.
@@ -396,6 +489,77 @@ an edit page is a check among its `iconActions`. Actions on a multi-selection, s
`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.
A bar under a sheet drops its actions and keeps its title and back or close button. `CommonScaffold` fades the actions
out with the entrance of a sheet laid over its page, or over the sheet it is a page of, so none on the layer below
reads as an action of the sheet on top; `sheetCoverOf` in `lib/widgets/sheet_navigator.dart` reads that entrance, which a sheet route hands
down through `SheetCoverRouteMixin`, as `SnapSheetRoute` and `ModalSideSheetRoute` do.
## Row Actions And Sort Mode
A list row or card carries no trailing more button. Its secondary actions go into a `ContextMenuRegion`, which opens
them on a right click, a touch long press, the menu key or Shift+F10 of a focused row, and, under an enabled
`RemoteFocusAdapter` (Android TV), a select key held past the long-press timeout; a short select there still presses
the row, on key up rather than key down. A tap keeps the row's main action. A row whose tap does something other than
open its page, as a profile card's tap selects the profile, may carry a trailing button to that page, as iOS's detail
button does, built as a `DetailButton` so a desktop's compact density cannot pull it off the column the indicator and
the handle share; a trailing button that repeats the row's own tap is never added. A row whose tap opens a page shows
a `DisclosureIndicator` there instead, as a script or a rule-set proxy provider does. A provider opens its content on
a tap, read-only unless it is a local text file the app owns, and keeps a second action on its trailing button: editing
for a proxy provider backed by a text file, the options dialog for an app rule provider.
A long press can mean only one thing on a row. Where rows have no context menu, as in a plain list editor, the global or
profile rules, the on-demand SSIDs or the custom proxies, a long press on the row drags it and there is no sort button.
Where they have one, the list reorders only in a sort mode. When the list is the page, `CommonScaffold` owns the mode as
it owns search and selection, and the page passes `canSort` for the toggle in its bar. Changes apply as they are made,
so nothing confirms them: while the mode is on, the bar shows only its toggle, selected, and the back or close button
fades in place rather than offer the same way out twice, as iOS hides its back button while editing; a system back still
ends the mode. A list that is one section of its page, as the custom profile's proxy groups are, keeps its own
`SortModeScope` around its rows and its toggle in its header and leaves the page bar alone, back button included; its
header keeps adding while it sorts, and a system back ends its mode before it leaves the page. Each row sits in a
`SortableItem`, which reads the scope: while it sorts `DecorationListItem` drops its tap and context menu, and
`SortableTrailing` springs a `SortHandle` in from the row's trailing edge while the trailing it replaces shrinks away.
The motion is painted, not built, and the row relayouts only while the two widths differ, since every row on screen
animates at once. A page that only reorders, such as the profiles sort sheet or a group's members, shows its
`SortHandle`s without a mode. A handle drags on press, and with focus a select key lifts its row for the up and down
keys to move through the enclosing `SliverReorderableList`'s own `onReorderItem`, which is how a remote sorts.
## Quick Edit
Quick edit, the compose button that opens what a page edits as text in an `EditorPage`, appears only where that text is
the whole of it: a full list or section, such as a custom profile's rules, its proxies as share links, or its DNS, NTP
and sniffer sections, or one isolated item, such as the definition of the proxy group or proxy a form edits. A page
that edits only part of something other settings also write offers none, since its document would show a fragment and
reject what it leaves out. The app-wide DNS page is that case, a few keys laid over the DNS of every URL and file
profile, so only the profile's DNS spec mixes in `OverrideQuickEdit`. So is the custom profile's TUN page: it picks a
few keys of a `tun` section the network settings own, so its spec does not mix in `OverrideQuickEdit`.
## Where A Route Opens
On desktop each navigation destination has its own `Navigator` (`_NavigationPage` in `lib/pages/home.dart`); on mobile
there is none, and everything goes on the root navigator, `rootNavigatorKey` in `lib/common/navigator.dart`. A route
takes the navigator of the context it opens from: `BaseNavigator.push`, `showExtend`, `showSheet` and `showSnapSheet`
all do, so a page or sheet opened from a destination stays beside the navigation rail, leaves the rail usable, and
stays in that destination's stack when the user switches away and back. `sheetNavigatorOf` lifts a sheet out of a
paged sheet's own `SheetPagesNavigator`, never past the destination.
The root navigator is only for what belongs to no single destination:
- dialogs, which `dialogs` in `lib/common/dialog.dart` hosts on the root unless it is given a context, and the notifier;
- a gate in front of the whole app, such as `requestDisclaimerConsent`;
- work that starts with no page behind it: a deep link, the tray, a hotkey, the update check.
Never reach for the root to open a page or sheet from a page, whether through `rootNavigatorKey`,
`globalState.navigatorKey`, `rootNavigator: true` or `useRootNavigator: true`, and never push a page from the context of
something on the root, such as a dialog: the page lands on the root too and covers the rail. The add profile sheet is
the one exception. `addProfileFormFile`, `addProfileFromLink` and `addProfilesFromLinks`, which a deep link also reaches,
close it with a root `popUntil` before switching to the profiles tab, so `showAddProfilePage` opens it on the root and
takes the caller's context as `origin`; a page the sheet leads to opens from `origin`, as `AddProfileView._toAddCustom`
does.
The mistake only shows in the desktop layout, and `TestApp` has nothing but the root navigator. A test that guards
where a route opens hosts the page in a nested `Navigator`, as the desktop case of a new custom profile in
`test/features/profile_views_test.dart` does.
## Settings Rows
`lib/widgets/config_item.dart` holds the shared settings-row vocabulary: `ConfigToggleItem`, `ConfigOptionsItem`,
@@ -410,6 +574,11 @@ in `lib/views/config/network.dart` and `_appSettingToggle` in `lib/views/config/
`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.
In an inset grouped list (`generateSectionV3`, `generateAnimatedSection`), a row's subtitle shows its value or state,
never what the setting does. As iOS settings do, the explanation goes in the group's `footer`, drawn by `ListFooter`,
one sentence per setting that names it, and a section header carries only its title. Flat `generateSection` lists keep
their descriptions on the row.
## Keyboard Insets
The soft keyboard moves `MediaQuery.viewInsets` on every frame of its animation, so whatever depends on them rebuilds
@@ -425,8 +594,46 @@ which runs every one of those rebuilds inside the layout phase; in a profile tra
- A widget high in the tree reads the aspect it needs (`MediaQuery.paddingOf`, `viewInsetsOf`, ...), never
`MediaQuery.of` or `MediaQuery.removePadding(context: ...)` on its own context, which subscribes it to every inset
change. Move such a read into a small widget of its own below it, as `_AppMediaQuery` in `ThemeManager`,
`_BodyPadding` in `lib/pages/home.dart`, the keyboard spacer in `CommonScaffold` and the keyboard watch in
`SnapSheet` do.
`_BodyPadding` in `lib/pages/home.dart` and `_SheetKeyboard` in `SnapSheet` do.
- A bottom sheet makes room for the keyboard itself: `_SheetKeyboard` lays the content out above it and removes the
bottom inset from the content's `MediaQuery`, so nothing inside a sheet, `CommonScaffold` included, adds keyboard
space. A nested sheet must not carry the keyboard through its pages: `NavigatorResizable` learns a page's new size a
frame late, and the sheet would trail the keyboard by a frame.
## Page Arrival
A pushed page is built, laid out and painted through every frame of its transition, so whatever it starts on arrival
(loading and decoding images, building the rows its data brings in) lands inside the animation. Impeller has no
raster cache, and every offscreen pass is paid again each frame on a phone GPU.
- Work a widget would start as it mounts waits for the route to settle. `RouteSettledMixin`
(`lib/widgets/route_motion_hold.dart`) calls `didSettleRoute` once the route that brought the page in has arrived,
and at once on a page that already sits still; `whenRouteSettled` (`lib/common/navigator.dart`) does the same for a
one-off future, and both wait out the frame a `HeroController` builds a pushed route offstage with its animation
pinned to completed. `CommonTargetIcon` and `PackageIcon` show what is already in memory at once and load the rest
after that. An icon still loading leaves a blank slot of its size and shows its fallback only once the load ends
without an image, so a grid of placeholders is never painted through the transition only to be replaced.
- A scroll view of image tiles takes `arrivalScrollCacheExtent(routeSettled)`: the rows past the viewport would
otherwise be built in the same frame as the ones on screen, which for a screen of icon cards is most of the push.
The icon grids (`IconGridScrollView`) and the access control list do this, and so does `ListEditorPage`, whose
rows are heavy enough that only its list rebuilds as the route settles, reusing the rows already built.
- A page whose content waits on what it loads on arrival (lists read from the database, file sizes, the installed fonts)
shows its `NullStatus` illustration, with no label, inside a `NullStatusSwitcher` until all of it is in, then switches
to the finished content in one step, as the custom profile, resources and font family pages do. Never a skeleton
screen of placeholder rows: it has to guess how many rows are coming, fills in piece by piece, and rebuilds every row
it stood in for. `SkeletonText` is for a single value inside a card that is already on screen, such as the dashboard's
service status. `holdsArrival` keeps the status a switcher opens on for at least 400 ms, about a route transition,
so a load that finishes just after arrival does not flash it; the connections, resources and font family pages set
it.
- `GlyphPainter` paints a translucent glyph through an offscreen layer. A placeholder repeated in every tile blends its
color over the surface instead of carrying an alpha.
- `PagedSheetRoute` and `CommonRoute` share
`SharedXPageTransition` (`lib/widgets/page_transition.dart`) and its `pageSlide`, a quarter of the page's width as
Android 14 slides pages, so a page moves alike in a sheet and on its own. The transition fades a page by painting the surface over it
and needs no opacity layer: the page on top shows only on its side of the point where the two fades cross, opaque
over the other, so neither shows through the other. A page below that does not fade with it, such as the home
route under a `CommonRoute`, is faded by the page on top painting the surface over it. `CommonDesktopRoute` fades in
place through a plain 200 ms `FadeTransition`.
## State Management
@@ -443,7 +650,7 @@ Provider files in `lib/providers/`:
- `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.
- `state/custom.dart`: custom profile 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.
@@ -507,14 +714,14 @@ 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
`launch.dart`, or `system_dns.dart`. Those four 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
barrel never re-exports one of those four modules, nothing under `lib/common`,
`lib/enum` or `lib/models` other than those four 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
@@ -611,30 +818,77 @@ on/off signal.
## 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.
counter; a schema change bumps it and goes into the one step the coming release takes, as the Migrations section of
`.agents/rules.md` describes.
Tables:
- `Profiles`
- `Scripts`
- `Rules`
- `ProfileRuleLinks` (`profile_rule_mapping`)
- `ProxyGroups`
- `DisabledRules` (`disabled_rules`)
- `ProxyGroups` (`proxy_groups`)
- `CustomProxies` (`custom_proxies`)
- `ProxyDialers` (`proxy_dialers`)
- `IconRecords` (`icon_records`)
- `IconSets` (`icon_sets`)
- `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.
`Profiles.type` is `file`, `url` or `custom`, fixed when the profile is created. A rule belongs to whoever its
`profile_id` names: no profile makes it global, a file or url profile's rules are what its standard extension adds, and
a custom profile's rules are all the rules it has. `DisabledRules` lets a profile leave out a global rule. Proxy groups
and dialers exist only on custom profiles. Every one of these rows cascades with its profile. 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.
A custom profile has no file and no extension: `SetupAction.getProfile` builds its config from an empty map plus its
groups and rules, and `clashConfigProvider` answers an empty `ClashConfig` for it. Its groups name app-level custom
proxies directly, and the config declares only the ones some group names; they take nodes from every file or url
profile too, each a file proxy provider under its label. A custom profile is never a provider itself. App-level
`ClashProviders` rows are rule providers only, and only custom profiles are offered them.
A custom profile names groups, local proxies, provider profiles and rule sets by name, mihomo's own strings included
(a DNS server's `#proxy`, a `rule-set:` policy key, the sniffer's lists). `lib/models/references.dart` reads and
renames every such place, and renames, delete prompts, provider injection and the profile checks all go through it;
`test/models/references_test.dart` fails when `config.go` reads a name somewhere it does not. A rename is detected
where the row is written, so every path carries it. A local proxy may not share a name with any custom profile's
group, as both live in mihomo's one namespace; where older data has both, the name means the group in that profile
and a proxy rename leaves it alone. `Profiles` writes rows one at a time from the state as it then stands, so a rename
rewriting custom profiles is never written over by a put issued before it landed.
An app-level rule set's behavior and format are what the Core's `compileRuleSet` found in its source, never a user
choice. The source stays at `getProviderCachePath(fileName)`; a domain or ipcidr set also gets an `.mrs` beside it, and
that is what the config loads, while classical rules have no mrs form and load from the source. The Core downloads a
remote set too and mihomo sees it as a `file` provider, so a sync goes through `ClashProvidersAction`, not mihomo's
HTTP vehicle. `ClashProvidersAction.prepare` rebuilds a missing cache while the config is built, since backups carry
only local sources and sets saved before compiling existed have no `.mrs`.
`IconSets` and `IconRecords` only feed the proxy-group icon picker. A set is an icon gallery in the format Quantumult X
and Loon subscribe to, kept whole as one JSON column; a record is an icon the user saved on a group, written on save and
never by merely displaying it. A group stores the icon string itself, a URL or a data URI, never a reference to a set,
so renaming, syncing or deleting a set leaves every group as it was.
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.
### Unreadable Stored Data
Preferences, database rows and backups can hold values this build cannot read: an enum a newer build wrote before a
downgrade, a fork sharing the data directory, a hand edit. Reading them gives up the smallest unit it can and never
fails startup or a whole restore.
- `Config.fromJson` and every `safeFromJson` decoder go through `decodeSalvaging`, which keeps each value the strict
decoder accepts, descending into maps and lists, and leaves the rest at their defaults; a list or section none of
whose entries can be read falls back to its default rather than to empty. Only the migration uses
`Config.strictFromJson`, since it tells legacy data apart by the failure. A field's own `fromJson` throws on what it
cannot read instead of restoring a default for the whole field, so the salvage around it drops just that entry.
- DAO entity queries read through `TableInfo.readable`, which skips a row it cannot map and leaves it in the table. A
profile's JSON columns that hold selection state, subscription info or overrides fall back to empty instead, so the
profile stays listed.
- Rules, disabled rules and proxy groups are the exception and read strictly, in the app and from a backup alike:
skipping one would quietly change how traffic is routed, so a damaged row fails loudly instead.
- Code that deletes files or rows by comparing against what a query listed must use raw ids (`ProfilesDao.ids`,
`ScriptsDao.ids`, `ClashProvidersDao.fileNames`), or it deletes what a skipped row owns. Startup reorders profiles
with `Profiles.reorder`, which writes rows but never deletes them.
## Manager Stack
Managers are nested `InheritedWidget`/`StatefulWidget` components built by `buildManagerStack()` in `lib/application.dart`:
@@ -724,12 +978,12 @@ rewritten; each owner reads the flag where it would otherwise act:
`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`
- Core listeners: `SetupAction` never calls `startListener` or `stopListener`, for a run or for an on-demand
suspension, 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
- Wi-Fi SSID: `ConnectivityManager` never reads the SSID and `LocationPermissions.refresh` 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.
@@ -750,18 +1004,17 @@ that asks the OS for a permission, a global shortcut or a registration, must con
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
The desktop Go core and the Rust helper are built by a Dart build hook; Android's Core is built by Gradle (see Android
Core Build). `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
input into a `BuildRequest` (repository root, `Target`) 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`).
not start, 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
@@ -777,8 +1030,8 @@ What the hook protocol forces:
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).
- Android: nothing. The hook returns without building, because it runs once per architecture while gomobile packs
every ABI and one set of Java classes into a single AAR.
- 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
@@ -795,7 +1048,7 @@ Setup keeps its own cache under `.dart_tool/setup_build_cache/v1/` because it bu
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.
effective build configuration, `setup_hooks` sources, target flags and the Go toolchain.
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
@@ -846,44 +1099,63 @@ Windows helper integrity/version check:
Build configuration defaults live in `plugins/setup/setup_hooks/lib/src/options.dart` and can be overridden via the root
`build_config.yaml`.
`core_version` in `build_config.yaml` is the mihomo release tag the `core/Clash.Meta` fork is based on. Upstream injects
its version only from its own CI, so the fork alone reports the placeholder `1.10.0` in `/version` and in protocol
user agents; `GoBuilder` passes the value as `-X github.com/metacubex/mihomo/constant.Version=...` for every build,
local and CI alike. Bump it whenever the submodule moves onto a new upstream release.
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
#### Android Core Build
`: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:
`android/core/build.gradle.kts` registers `bindCore`, which runs `gomobile bind` on `core/mobile`. `:core` cannot
depend on the AAR itself: `bundleAar` refuses a local AAR in a library, which would break `./gradlew assemble` and
Android Studio's Make Project. It takes the AAR apart instead, `classes.jar` as a file dependency and `jni/` through
an `unpack<Variant>CoreJniLibs` task per variant handed to `variant.sources.jniLibs.addGeneratedSourceDirectory`; the
legacy `sourceSets` DSL rejects a task-built directory. Gradle therefore runs the Go build before any task that needs
its classes or libraries, in every variant and whether or not Flutter started the build, and its up-to-date check over
`core/` (tests, `testdata` and `cmd/` left out), `build_config.yaml`, the Go version, the ABIs, the API level and the
NDK version stands in for the hook's cache.
- `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.
- `gomobile` and `gobind` come from the `tool` directives in `core/go.mod` and are built into the task's temporary
directory, so their version moves only with `go.mod`. `bindCore` finds `go` on `PATH` or in the Homebrew and Go
installer locations that Android Studio's GUI `PATH` leaves out, and puts the JDK running Gradle first for
gomobile's `javac`.
- The ABIs come from the `target-platform` property Flutter passes, and default to all three for a Gradle build Flutter
did not start. `ANDROID_HOME` and `ANDROID_NDK_HOME` are AGP's SDK and its `ndkVersion` NDK, so the Core and the app
share one NDK, and `-androidapi` is the app's `minSdk`.
- `tags`, `go_ldflags` and `core_version` come from `build_config.yaml`, the file the hook reads.
- Both Cores take their GODEBUG defaults from the `go` line of `core/go.mod`, whatever toolchain builds them. The
desktop Core gets them from the Go command because `core` is its main module. gomobile links the library as the main
package of a module it generates, whose `go` line is the toolchain's, so `bindCore` hands the desktop Core's
`DefaultGODEBUG` (from `go list` on `./cmd/core`) to the Android Core through `-X=runtime.godebugDefault`. Without
it, a toolchain newer than that line would move only Android's TLS, x509 and runtime defaults. Raising the `go`
line therefore changes both Cores' behaviour at once; pin a setting with a `godebug` line in `core/go.mod` to hold
it back on both.
- The TLS settings among them do nothing for proxy traffic. Every TLS connection the Core makes goes through
`metacubex/tls` or `metacubex/utls`, ports of `crypto/tls` that hard-code their version's defaults and never read
GODEBUG, so `tlssha1` and `tlsmlkem` cannot be held back. They still parse and verify through the standard
`crypto/x509` and `crypto/rsa`, and both run before `skip-cert-verify` is consulted, so `core/go.mod` keeps
`x509negativeserial=1` and `rsa1024min=0`: a server certificate with a negative serial number or an RSA key under
1024 bits would otherwise fail the handshake even on a proxy that skips verification.
- gomobile names the library `gojni`, both as the file it builds and in the `System.loadLibrary` of the `go.Seq`
runtime, with no option to change it. `bindCore` rewrites that string constant in `go/Seq.class` to `clash` as it
extracts `classes.jar`, and the unpack task renames `libgojni.so` to `libclash.so`; each fails the build unless it
finds what it rewrites, the constant exactly once. Go writes no `DT_SONAME` into a c-shared library, so the ELF
needs no change.
- The AAR's own `proguard.txt` goes with the rest of it. `android/core/consumer-rules.pro` keeps `go.**` and
`com.follow.clash.core.mobile.**`, the classes gobind's C glue looks up by name.
- The CI `android` job compiles `:core` without Flutter, so it passes `-Ptarget-platform=android-arm64` to build one ABI.
#### 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
- `bindCore` drops `-s` and `-w` from `go_ldflags`, and gomobile strips nothing itself; 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.
- `:core` sets `packaging.jniLibs.keepDebugSymbols` so its own strip task leaves `libclash.so` intact on the way into
`:app`'s merged native libs, which is where the Crashlytics plugin reads it.
- `: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
@@ -894,7 +1166,10 @@ Crashlytics NDK symbolicates a native crash only from libraries that still carry
## 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.
- `proxy`: system proxy configuration. On Windows the package installer and uninstaller kill the app, and then run
`FlClash.exe --clear-stale-proxy`, the installer once the new files are in place; the runner hands it to the plugin
before any engine starts, and it turns off only a proxy still set to FlClash's `127.0.0.1:<port>` with nothing
listening on that port.
- `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
@@ -969,10 +1244,11 @@ table. `lib/common/window.dart` and `lib/manager/window_manager.dart` are the on
capture on top of it.
- Windows: with `titleBarStyle: hidden`, `WM_NCCALCSIZE` takes the frame insets from `AdjustWindowRectExForDpi` and
keeps the client rect at the top edge (plus the frame height when maximized, so the taskbar stays uncovered; plus
1 px on Windows 11, which draws the top border only into a non-client strip; none on Windows 10, where DWM draws
the whole caption over any strip shorter than one). Windows 10 gets its top border the Windows Terminal way
instead: the DWM frame is extended over the caption height, the plugin places the Flutter view 1 px lower and
paints that row black, which DWM treats as alpha 0, and hit-tests it as `HTTOP`; see `plugins/window/README.md`.
one physical pixel on Windows 11, which draws the top border only into a non-client strip; none on Windows 10,
where DWM draws the whole caption over any strip shorter than one). Windows 10 gets its top border the Windows
Terminal way instead: the DWM frame is extended over the caption height, the plugin places the Flutter view one
physical pixel lower and paints that row black, which DWM treats as alpha 0, and hit-tests it as `HTTOP`; see
`plugins/window/README.md`.
The plugin therefore owns the view placement on `WM_SIZE`. 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
@@ -1019,9 +1295,11 @@ the singleton entry point: `RustLib.init()` runs before any call and `RustLib.di
- `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).
- `ipc/` implements the desktop socket server: `frame` (length-prefixed framing), `outbox` (the byte-bounded send
queue), `access` (socket mode, the Unix peer uid check, and the peer PID), and `server` (bind, the accept loop, and
the read and write halves of a connection). Each `Server` runs a current-thread tokio runtime on an `ipc-server`
thread of its own and holds no global state, so I/O is event-driven on every platform and closing or dropping the
handle releases the socket. It serves one peer at a time; a second one waits in the backlog.
- `script/` runs profile override scripts on QuickJS through `rquickjs`.
- `editor/` backs `plugins/code_forge`: a `ropey` buffer, fold ranges and bracket matching, indent guides for the
viewport, and completion words. `api/editor.rs` exposes the buffer as the opaque `RopeBridge`; the selection
@@ -1032,7 +1310,13 @@ the singleton entry point: `RustLib.init()` runs before any call and `RustLib.di
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
`IpcServer.bind` resolves once the address is bound, `events()` attaches the one event stream, `send` only enqueues
and is a synchronous call so frames keep the order they were sent in, and `close` joins the thread. A bridge function
that takes a `StreamSink` must not return an error: the generated Dart code drops that call's future, so an `Err`
surfaces as an unhandled async error instead of reaching the caller. That is why binding is its own call and
everything after it is reported as an event.
What a platform does not use, it does not compile. `interprocess`, `tokio` 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.
@@ -1054,7 +1338,7 @@ returns the JSON the script produced. Nothing about the script runs in Dart.
- 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
- `rust/tests/fixtures/profile_script.js` is the compatibility regression: an extension script 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.
@@ -1079,8 +1363,11 @@ The Dart layer only launches the helper's `install` command through `ShellExecut
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.
Elevation goes through `pkexec` so polkit raises the system prompt. When pkexec is missing, or exits 127 because no
polkit agent runs (common under tiling window managers), it falls back to `sudo`: cached credentials first, then a
password the app asks for and hands to `sudo -S` on stdin. A 126 means the user dismissed the polkit dialog and is final.
- `FlClashHelperService install`, run through `pkexec` so polkit raises the system prompt, writes
- `FlClashHelperService install`, run through `LinuxElevation` (`lib/common/linux_elevation.dart`), 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
@@ -1107,12 +1394,12 @@ is registered at package install, and `Linux.registerService` asks for elevation
- 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.
readiness is the `stat` check, and the same elevation sets the setuid bit on the bundled Core as before.
A setuid Core is a root process for whoever gives it an address to dial (`argv[1]`), so its mode never lets others
execute it: macOS runs `chown root:admin` and `chmod 4750` (and refuses an account outside the `admin` group, which
could not run the result), and a Linux host without systemd hands it to the requesting user's primary group under
`pkexec`. `System.isPrivilegedStatOutput` reads a Core that others can execute as unauthorized, so one that an earlier
could not run the result), and a Linux host without systemd hands it to the primary group of the user that
`PKEXEC_UID` or `SUDO_UID` names. `System.isPrivilegedStatOutput` reads a Core that others can execute as unauthorized, so one that an earlier
version marked `+sx` is re-authorized on the next TUN enable.
In every Flutter build mode `/start` opens the fixed Core executable beside the Helper without write/delete sharing,
@@ -1136,8 +1423,11 @@ rebinding through an owner-account browser, which would otherwise pass the Windo
authorities are `127.0.0.1`, `localhost` and the Linux Unix-socket placeholder `FlClashHelperService`. The Helper grants
no CORS access.
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. Who may call them is decided per connection, before any request is read: the Linux
Endpoints bind only to `127.0.0.1` on Windows and to `/run/flclash/helper.sock` on Linux, and do not use
request-token authentication. The Windows port is picked by the system, because Hyper-V and WinNAT reserve ranges that
can cover any fixed one, and published as the `Port` DWORD of a volatile `Runtime` key under the service's own registry
key, which only administrators can write; `HelperClient` reads it per request and falls back to 47890, where a Helper
from before listens. Who may call them is decided per connection, before any request is read: the Linux
socket drops a peer whose `SO_PEERCRED` UID is not the owner's, and the Windows listener (`service/peer.rs`) traces the
connection through the TCP table to the process holding its client end and drops it unless that process runs as the
owner SID the service was registered with. The Windows Core runs as SYSTEM and dials whatever pipe `/start` names, so
+26 -18
View File
@@ -17,12 +17,13 @@ dart setup.dart windows
dart setup.dart android
```
The Go core and the Rust helper build automatically: Flutter runs
The desktop Go core and the Rust helper build automatically: Flutter runs
`plugins/setup/hook/build.dart` on every `flutter build` and `flutter test`,
and the hook drives `CoreBuilder` from the `setup_hooks` package in
`plugins/setup/setup_hooks/`. 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
`plugins/setup/setup_hooks/`. Artifacts land in `libclash/`. Android's Core is
the AAR the `:core` Gradle module builds with `gomobile bind` before anything
compiles against it; that needs `go` on `PATH` and nothing else, since the
gomobile tools come from `core/go.mod` and the NDK from AGP. The hook reruns only when Go, Rust, or
`setup_hooks` inputs change; the fingerprint cache lives in
`.dart_tool/setup_build_cache/`. To force a rebuild, delete that directory:
@@ -49,15 +50,16 @@ hooks:
build_assets: true # false does the same for the Rust library
```
CI flips `setup` to `false` with `yq` before `dart run tool/changelog.dart` and
`flutter test`, and restores the file afterwards; no test loads the Go core. `rust_api`
stays on there because the editor tests in `test/plugins/code_forge/` load the Rust
library the hook builds; the release-notes job, which runs no tests, flips both.
CI flips `setup` to `false` with `yq` in its throwaway checkout before
`dart run tool/changelog.dart` and `flutter test`; no test loads the Go core. `rust_api`
stays on there because the editor tests in `test/plugins/code_forge/` and the socket
test in `test/core/desktop/ipc_native_test.dart` load the Rust library the hook
builds; the release-notes job, which runs no tests, flips both.
Never commit `false`: a build with it set stages whatever `libclash/` already
holds and bundles no Rust library, which is why `setup.dart` refuses to package
while it is set. Locally, `false` is worth setting for a Dart-only test loop,
but the hook cache keys on the user-define, so the first build afterwards runs
the hooks again. The editor tests still need the Rust library: keep `rust_api`
the hooks again. The editor and IPC socket tests still need the Rust library: keep `rust_api`
on, or point `FRB_DART_LOAD_EXTERNAL_LIBRARY_NATIVE_LIB_DIR` at a directory that
holds a `rust_api` library built from the same sources.
@@ -175,7 +177,8 @@ 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.
latest-intent desktop lifecycle convergence. `ipc_native_test.dart` runs the transport over a real socket through
the Rust `IpcServer`; the server's own behavior is covered by `cargo test` in `plugins/rust_api/rust`.
- `test/core/service_test.dart`: `CoreService` composition and terminal close behavior.
- `test/core/protocol_contract_test.dart`: shared Dart/Go method and event-envelope compatibility, including event batches.
- `test/providers/action_test.dart`: Core start/restart orchestration and overlapping restart requests.
@@ -185,12 +188,13 @@ What those suites own:
## Native Component Verification
The CI Go-wrapper checks can be reproduced without CGO:
The CI Go-wrapper checks can be reproduced without CGO, the Android files included:
```bash
cd core
CGO_ENABLED=0 go test .
CGO_ENABLED=0 go vet .
CGO_ENABLED=0 go vet . ./cmd/...
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go vet -tags=with_gvisor . ./mobile ./tun ./platform
```
The Windows Helper's loopback/session protocol tests are host-independent by default. Windows CI additionally enables its
@@ -209,6 +213,7 @@ registered with `java_home`, so export it by prefix:
```bash
cd android
export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home" # elsewhere: any JDK 17
./gradlew :core:compileDebugKotlin -Ptarget-platform=android-arm64 # builds the gomobile AAR for one ABI
./gradlew :service:compileDebugKotlin
./gradlew :app:compileDebugKotlin
```
@@ -284,7 +289,8 @@ works as is for local previews. `tool/build_site.sh [dir]` assembles it with the
`gh`. Without `gh` access the page falls back to the newest version in `CHANGELOG.md` and lists the files without sizes.
It then runs `tool/render_site.mjs` (Node), which writes the English page and a `zh/` copy for search engines: page
text, the first changelog releases, canonical and `hreflang` links, JSON-LD and `sitemap.xml`, all from the strings,
changelog parser and release markup in `site/assets/shared.js`. `SITE_URL` overrides the published URL these point at.
changelog parser and release markup in `site/assets/shared.js`, plus the GitHub star count from `release.json` so the
nav does not grow when the page loads. `SITE_URL` overrides the published URL these point at.
On the built site each URL keeps its language: the language button links to the other page and carries the section in
view, the chosen platform and the changelog search. Only the English root sends readers who chose or prefer Chinese on
to `zh/`, unless its URL carries `?lang=en`: the button on `zh/` links there with it, so getting back to English never
@@ -311,8 +317,7 @@ In the public repository every branch push runs the `dart` job; in the private
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
bash tool/hooks/test.sh
flutter pub get
dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart
flutter analyze --no-fatal-infos
@@ -321,15 +326,18 @@ flutter test --reporter expanded --coverage
dart run tool/check_coverage.dart coverage/lcov.info 75
```
`tool/hooks/` holds the scripts the git hooks run, and `tool/hooks/test.sh` runs every `*_test.sh` beside them, so a
new hook script needs only its test file next to it, not a workflow step.
Run `flutter analyze` before committing; CI fails on anything it reports.
Release builds run only for `v*` tag pushes; pull requests trigger nothing.
Root analysis excludes `plugins/**`, and root tests do not discover nested
plugin packages, so parallel jobs validate the rest from their own package
directories: `plugins` (local Flutter packages and the setup build tool), `go`
(the Core wrapper, plus 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
(the Core wrapper, plus a vet of the Android files), `android` (JVM unit tests
for `:common`, `:service` and `:app`, with the Flutter compile tasks excluded
and the gomobile AAR built for one ABI), `rust` (both crates), and a
Windows runner for the helper's `windows-service` feature. 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.
+21 -1
View File
@@ -17,7 +17,7 @@ listed in the SDK's `CHANGELOG.md`.
became the default desktop renderer, Intel (x86_64) Macs flicker across the whole window while scrolling or
animating, and text can render garbled. Apple Silicon is not affected.
- **Fix upstream:** landed on `main` before 2026-08-25 and verified on `3.48.0-1.0.pre-404`; it is not in any
3.47.x hotfix (checked through 3.47.5).
3.47.x hotfix (checked through 3.47.6).
- **Workaround:** the release job for `macos-15-intel` (`arch: amd64`) runs
`plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist` before `dart setup.dart`, so only the Intel
DMG falls back to Skia. The checked-in `Info.plist` does not set the key, so local builds and the arm64 DMG keep
@@ -30,6 +30,26 @@ listed in the SDK's `CHANGELOG.md`.
dashboard pages on an Intel Mac with its integrated GPU driving the built-in display; an external display can
switch to the discrete GPU and hide the flicker.
## Wrong-size back buffer on macOS
- **Bug:** [flutter/flutter#185394](https://github.com/flutter/flutter/issues/185394). `removeSurfaceForSize:` in
the macOS engine's `FlutterSurfaceManager.mm` hands out the youngest idle cached surface without checking its size.
When the window flips between sizes while the window server still holds the previous surface, which dragging a
window edge back and forth does, a request for one size gets a surface of another. Impeller's Metal texture then
disagrees with its descriptor, the render target drops it, and `Canvas::SetupRenderPass` dereferences a null
texture on the raster thread. Debug builds log `The texture and its descriptor disagree about its size` and
`Store action needs resolve but no valid resolve texture specified` first; release builds just crash.
- **Fix upstream:** [flutter/flutter#192522](https://github.com/flutter/flutter/pull/192522), still open; 3.47.6
ships the same `FlutterSurfaceManager.mm` as 3.47.4.
- **Workaround:** `BackBufferCacheGuard` in the `window` plugin's
`macos/window/Sources/window/BackBufferCacheGuard.swift`, installed from `WindowPlugin.register`, replaces
`-[FlutterBackBufferCache removeSurfaceForSize:]` at runtime and answers nil when the cached surface is not the
requested size, so the engine allocates a new one. It does nothing if the engine no longer has that class or
selector.
- **Remove when:** `FLUTTER_VERSION` moves to a stable that contains #192522 (check the SDK `CHANGELOG.md` for a
3.47.x cherry-pick). Before dropping it, run a debug build without the guard on that SDK and drag a window edge
back and forth for a minute; neither validation line may appear.
## Windows view visibility forwarded by the runner
- **Bug:** the Windows embedder's `WindowsLifecycleManager` counts the Flutter view as a window of its own and keeps
+19 -15
View File
@@ -1,10 +1,10 @@
# 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.
FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with Flutter. It supports Android, Windows, macOS, and Linux, using Material You from `material_ui` as the base with Apple's Human Interface interaction conventions layered on top.
## Version Notes
- Flutter stays on the 3.47.x line. Release CI pins 3.47.4 and is the source
- Flutter stays on the 3.47.x line. Release CI pins 3.47.6 and is the source
of truth for release builds; `.fvmrc` and local SDKs may sit on another 3.47
patch. The editor in `plugins/code_forge` came from `code_forge` 10.14.0, which
needs Dart 3.13.2, so the CI pin cannot go below Flutter 3.47.2.
@@ -16,11 +16,11 @@ FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with
## Forked Dependencies
Two `pubspec.yaml` dependencies are pinned to a fork by commit SHA. Both forks
live under `chen08209`, the same account that owns this repository, so they are
One `pubspec.yaml` dependency is pinned to a fork by commit SHA. The fork lives
under `chen08209`, the same account that owns this repository, so it is
maintained in-house rather than tracked from a third party: advancing a pin is a
local decision, and there is no external maintainer to wait on for the patch
itself. What each fork still waits on is the *upstream* fix that would let the pin
itself. What the fork still waits on is the *upstream* fix that would let the pin
be dropped entirely, recorded below.
Each entry records what the fork changes and what has to be true before it can go
@@ -47,13 +47,11 @@ now, see the window plugin section of `architecture.md`.
directly, and upstream's `^2.0.0` constraint cannot co-resolve with it.
- Drop the fork when upstream publishes a release that accepts `win32_registry` 3.x.
`yaml_writer` — `chen08209/yaml_writer`, version 2.1.0.
- Adds `StringNode.quoteKey()` and applies it to map keys in `lib/src/node.dart`.
Upstream quotes values but emits keys verbatim, so a profile key needing quotes
is written as invalid YAML.
- Drop the fork once upstream quotes map keys by the same
`isValidUnquotedString` rule it already applies to values.
The `yaml_writer` fork (`chen08209/yaml_writer`) was replaced by
`lib/common/yaml_writer.dart`. Its key quoting let keys such as `!a`, `'a` or
`0x1F` reach the Core's yaml.v3 as a parse error or a non-string; a merge key
`<<` stays plain on purpose, because `package:yaml` keeps it as an ordinary key
and the Core applies the merge.
The `code_forge` fork (`chen08209/code_forge`) is frozen: its Dart code now lives
in the in-repo `plugins/code_forge` package and its Rust code in the `editor/`
@@ -69,9 +67,15 @@ 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
The `riverpod` set, `build_runner`, `drift_dev` and `intl_utils` resolve to their
latest releases. Two are held back:
- `freezed` 4.0.2 needs `analyzer` 14, while `intl_utils` 2.8.16 still requires
`analyzer` ^13. It moves once `intl_utils` accepts analyzer 14.
- `test` 1.32.0 needs `test_api` 0.7.14, and `flutter_test` on Flutter 3.47 pins
0.7.12. It moves with the SDK.
When an upgrade fails version solving, record the blocker here with
what has to change upstream before the pin can move, so the next
`flutter pub outdated` run does not rediscover it.
+115 -44
View File
@@ -52,7 +52,18 @@ replaces the app's, so merge the ambient `XButtonTheme.of(context).style` into i
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.
with an enclosing clip or shape where it shows. Framework widgets that paint their own circular corners with no shape or
decoration to override, such as the `Switch` track, are left as Material draws them. Tooltips take theirs from
`withAppShapes`, which sets `tooltipTheme.decoration`.
### Length Units
Flutter lengths are logical pixels, the unit Android calls dp. Comments, test names, docs and reports give them in dp,
as in `48 dp`, never px: a reader takes px for physical pixels and misjudges the size by the device pixel ratio. Write
"physical pixel" in full where the code really works in them, as the hairline snapping in
`lib/widgets/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in
`tool/` and CSS under `site/` keep px. `test/lint/length_unit_test.dart` fails on a number followed by px in `lib/`
and `test/`.
CI gates formatting: `dart format --output=none --set-exit-if-changed lib test
tool plugins setup.dart` runs before `flutter analyze`.
@@ -95,7 +106,7 @@ judgment, and it lives in the rules below and in review. Whether comments are to
### Density
`tool/check_comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring
`tool/hooks/comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring
diffs under 20 added lines so small edits are never caught. The number is calibrated on this repository's own history:
healthy changes sit at or under 3.6%, while the core fix that prompted the gate ran 22.4%.
@@ -105,7 +116,7 @@ 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.
Behavior is pinned by `tool/hooks/comment_density_test.sh`, which CI runs through `tool/hooks/test.sh`.
When a change genuinely warrants more, raise the ceiling for that run rather than working around it:
`COMMENT_DENSITY_MAX=20 git commit`, or `SKIP=comment-density git commit` to step past it entirely.
@@ -123,7 +134,7 @@ how important it feels.
- **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
`core/delay.go`, and whoever changes that number must see the constraint on the same screen. This is the case a
comment is for; the bar is that no test and no `.agents/` entry could hold the fact instead.
Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;
@@ -140,10 +151,10 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
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
A launch mode that runs the Core as any other user has to widen `admit` in `ipc/access.rs`, and Windows
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, JNI, and
desktop IPC. Do not double-encode `arguments`, `result`, or event batches.
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, the
gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches.
- `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
@@ -151,19 +162,15 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
`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
- Every method of the callback interfaces in `core/mobile` (`TunHandler`, `ResultHandler`) returns an `error`. gobind
clears a Java exception and hands it to Go only for a method that declares one; otherwise the exception stays pending
and the next JNI call on that thread aborts the process, so a throw in `protect`/`resolveUid`/`resolvePackage`/
`onResult` would crash unrelated code. `core/mobile` turns the error into the answer the system gives when it will not
help: `false`, `-1`, or an empty package name.
- `TunHandler`'s hooks in `core/lib.go` check `callbacks` under `th.mu` before every call. `clear` drops the callbacks
while connections may still be resolving, and a call through the nil interface panics on a mihomo goroutine, outside
every recovery.
- The Android bridge resolves an owner in two steps — `ResolveUid` then `ResolvePackage` — because mihomo fills
`metadata.Uid` from a procfs lookup that Android Q closed off. Collapsing them back into one call that returns only a
package name is what left every connection reporting uid 0, so `UID` rules matched nothing.
- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to
@@ -182,10 +189,17 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
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.
- Core method handlers are plain functions that return their answer, registered in the `methodHandlers` table in
`core/method.go` through the adapter matching their shape. `dispatchMethodCall` already runs every call on its own
goroutine and `handleMethodCall` turns a panic into an `internal_error` answer, so a handler blocks for as long as its
work takes and never spawns a goroutine just to answer. Work that outlives the answer goes through `safeGo`, which
recovers; a bare `go` puts a panic outside every recovery and kills the process, which on Android is the whole
application. The exported Android entry points in `core/lib.go` do not reach `handleMethodCall`, so each one carries
its own recovery.
- A handler fails by returning an `error`: a `*MethodError` travels as it is and anything else under `core_error`. The
exception is `withMessage`, for the methods whose result the host reads as a message that is empty on success
(`_invokeMessage` in `lib/core/interface.dart`): there a plain error becomes that message and only a `*MethodError`
fails the call. Moving a method between the two changes what Dart receives.
- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in
`core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the
socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func
@@ -215,6 +229,10 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
used to mean "testing", which let a result and the state of a test overwrite each other and left cards spinning
forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a
reply arriving; core status leaving `connected` cancels every run in flight.
- Some constants in `lib/common/constant.dart` mirror the Core and move with it. `delayTimedOutValue` is the `-1` that
`core/delay.go` reports for a delay test that ran out of time; any other negative value failed outright.
`downloadTimeoutDuration` matches mihomo's `resource.DefaultHttpTimeout`, which bounds the Core's provider downloads
end to end. `shareLinkSchemes` are the schemes mihomo's `ConvertsV2Ray` reads, less http(s), which is a subscription.
- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background
scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the
batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData`
@@ -228,9 +246,10 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
- 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.
- No file in `core/` uses cgo: the only C in the Android library is the glue gobind generates during `gomobile bind`.
Android-only files take `//go:build android` and desktop-only ones `!android`, so `GOOS=android CGO_ENABLED=0 go vet`
checks the Android side without an NDK. Keep it that way; cgo in `core/` puts the NDK back in front of that vet and of
`go build ./...` on every developer machine.
## Lifecycle Rules
@@ -257,7 +276,12 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
- `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.
`ServiceState`; do not add a Flutter completion callback that creates a second lifecycle owner. What flows back is
the revision-stamped `stopped` report, which Flutter mirrors with `setRunning(false)` and never answers with a
decision of its own.
- Android `RunState` is derived in `ServiceStateMachine.publish`; do not assign it from a transition step, and do not
add a second "is it running" field beside `ServiceController.runtime`. Every divergence between the tile and the
service so far came from a copy that one path forgot to update.
- Android service callbacks are not automatically user intent. Route explicit Quick Settings, Always-on VPN, and revoke
actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.
- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the
@@ -278,9 +302,11 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
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
- Every native `show` returns whether the tray accepted the payload, and reports `false` instead of showing a broken
icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather
than suppressed until restart. Any test that mocks the `tray` channel must return `true` from `show`.
than suppressed until restart. Windows accepts a payload the shell refused, as at sign-in, and retries the icon
natively until it lands, so `Tray` still treats it as visible and `hide` reaches native to delete an add that may
have landed. Any test that mocks the `tray` channel must return `true` from `show`.
- Reading the Wi-Fi SSID is opt-in work, not ambient state. `ConnectivityManager` reads it only while `excludeSSIDs` is
non-empty, because that list is its only consumer through `suspendProvider`, and the read costs a blocking platform
call plus a location permission on Android and macOS. A second consumer must widen that gate, not drop it.
@@ -294,16 +320,40 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
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
re-reads `networkInfos` and does nothing when `onLost` already dropped the network, `updateDns` calls into the Core
only when the resolved list or the preferred network changed, and `stop()` clears the handler queue, so a network that
reports `onLosing` repeatedly costs two comparisons per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds
state that has to stay in sync with the map.
## UI Isolate Work
Everything the UI isolate runs synchronously shares a frame with build, layout and paint: 16 ms at 60 Hz, 8 ms at
120 Hz. Computation may take as long as it needs anywhere else, so this rule is about where work runs, not how much of
it there is.
- Work that grows with the user's data (profiles, rules, proxies, groups, dialers, icons, connections) and can take
more than about 8 ms on the largest data the app expects runs in an isolate, through `compute` or `Isolate.run`.
Thousands of rules or proxies and hundreds of groups are in that range. `makeRealProfileTask` in
`lib/common/task.dart` and the backup tasks in `lib/common/backup.dart` are the existing shape.
- Make the algorithm linear or cached first. Work superlinear in that data, such as a walk of the whole graph per
item, a scan of every group per group, or a list copy per inserted element, is a defect on any isolate; moving it
only hides the cost until the data outgrows the move.
- Sending data to an isolate copies it on the sending isolate, so hand the task the smallest input it reads. A value
the UI reads synchronously, such as a sync provider watched in `build`, becomes an async provider with a loading
state rather than a blocking call.
- drift runs SQL on its own isolate but maps rows into models on the caller's, so a watched query over a large table
costs the UI isolate on every emission, and any write to that table makes it emit again.
- Bounded work stays where it is: a provider recompute that reuses a cache, a single-item update, a search filter over
a list sized for scrolling.
- Decide on measurements taken at the expected maximum. A `flutter test` probe that counts provider recomputes, times
the provider body and hooks `debugOnRebuildDirtyWidget` shows what reruns and how it scales; its JIT timings are not
device numbers, so a case near the line is settled with a profile build. No gate checks this rule.
## Testing Rules
The `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage
collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper,
plus an NDK-backed `GOOS=android` vet that covers the `android && cgo` files the first two exclude; verify cross-language
plus a `GOOS=android` vet that covers the `android` files the first two exclude; verify cross-language
protocol behavior through shared Dart contract tests under `test/core/` and native platform build checks.
A Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` —
@@ -378,9 +428,7 @@ 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.
`container.listen(theProvider, (_, _) {})` in the harness, as `test/providers/action_test.dart` does for `scriptsProvider`.
A field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`,
`PageController`, `AnimationController` or `StreamController` must be released in the same file.
@@ -412,6 +460,17 @@ A public top-level declaration that nothing outside its own file references is d
riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or
typedefs are skipped: those are reached through the types they attach to, never by name.
`CommonScaffold` adds the height of its docked search and floating action button to `BottomInsetScope` around its
body, so a page that reads `BottomInsetScope.of(context)` with the context it builds the scaffold from gets the inset
without them, and the end of its content stays under the search field or the button. Read it below the scaffold,
inside `body: Builder(builder: (context) => ...)`. `test/lint/scaffold_bottom_inset_test.dart` enforces this by
scanning `lib/`.
Every reorderable list builds its drag proxy with `commonProxyDecorator`, which tells `DecorationListItem` it is lifted:
the row drops its separator and rounds from its corners in the run to a card of its own as it lifts, and back as it
drops. A list without it lifts a middle row with square edges. `test/lint/reorder_proxy_decorator_test.dart` enforces
this by scanning `lib/`.
A `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then
immediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown
throws "`…State.dispose failed to call super.dispose.`" in debug and profile builds. Declare the override as `void
@@ -446,7 +505,7 @@ failure, disposal, and any timer boundary that changes visible state.
## 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`:
`tool/hooks/commit_msg.sh`:
```text
<type>[(scope)][!]: <description>
@@ -503,6 +562,19 @@ Changelog: Per-profile override scripts
carries a `Changelog:` trailer. Commits missing a trailer reuse their subject; for `feat`, `fix` and `perf` the hook
says so without blocking.
## Migrations
An upgrade starts from the last release, not from the previous development build. Before writing a legacy reader, a
data move, or a test for one, check that the state it reads shipped: `git tag --contains <commit>` on the commit that
introduced it, or `git show <tag>:<path>` for the file as the release had it. A state that only ever existed between
two releases gets no migration; a development install holding it may lose that setting or fall back to its default.
The database takes one migration step per release. The newest step in `lib/database/database.dart` has not shipped
while `schemaVersion` at the last release tag is below its number; a schema change then goes into that step, raising
`schemaVersion` and the step's condition together so a development install runs the step again, instead of adding a
step of its own. Every operation in it checks the shape it finds, a missing table or column or a column under its old
name, so it can run again on a database that went through part of it. Its tests start from the released shape.
## Generated Code
Do not manually edit generated files under:
@@ -528,9 +600,8 @@ Strings live in `arb/intl_{en,zh_CN,ja,ru}.arb` — flat JSON, no `@` metadata.
`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.
Labels for enum values go through the exhaustive switches in `lib/common/l10n_labels.dart`, never
`Intl.message(<runtime string>)`. A key built from an enum name or a stored string is invisible to the analyzer, and a
failed lookup returns the key itself, so a stale key ships as `routeMode_config` in the UI rather than throwing.
`test/lint/dynamic_message_key_test.dart` fails on any `Intl.message(` in `lib` outside the generated code, and checks
that every locale carries the same keys and has a display name.
+141
View File
@@ -0,0 +1,141 @@
# Setting Changes
A changed setting takes one of three paths. The path depends on who reads the setting and when it reads it, not on
which page edits it. Choose the path when you add a setting or move one.
| Path | Who reads the setting | Watched provider | Acts | Effect |
|---------|---------------------------------------------|----------------------------------------|-------------------------------|--------------------------------------------|
| Update | Core, and Go can change it in place | `updateParamsProvider` | `CoreManager`, automatically | Patches the running config, no reload |
| Apply | Core, only when it parses a config | `setupPatchProvider` | `CoreManager`, automatically | Rebuilds the profile and reloads the Core |
| Restart | Android service, when it starts | `vpnOptionsProvider` | `VpnManager` asks the user | Stop and start of the service |
Update and apply run on their own, debounced. A restart never does: rebuilding the tunnel cuts every connection
through it, so `VpnManager` only shows a notifier and the user taps its restart action. A setting that only Flutter
reads takes no path.
## Update
`CoreManager` hands each change of `updateParamsProvider` to `SetupAction.updateConfigDebounce`. `updateConfig` then sends
`PatchClashConfig.toUpdateParams` to Go's `updateConfig` in `core/config.go`. That function writes the fields into the
running config and calls the runtime setter for each one, or `updateListeners` for the inbounds. Nothing is re-parsed.
The update path carries these fields:
- From `PatchClashConfig`: `mode`, `log-level`, `mixed-port`, `allow-lan`, `ipv6`, `find-process-mode`,
`tcp-concurrent`, `unified-delay`, `external-controller`, `secret`, `geo-auto-update`, `geo-update-interval`,
`geox-url`, and every `tun` field.
- From other settings: `NetworkProps.authentication` and `bypassPrivateRoute`, which `Tun.getRealTun` folds into the
TUN exclusions, and `AppSettingProps.checkCertificate`, sent as `skip-cert-verify`.
On desktop, `updateListeners` rebuilds the TUN from the patched `tun`. Enabling TUN may need authorization first. When
`requestAdmin` gets it newly granted, `updateConfig` restarts the Core instead of patching it. On Android,
`updateListeners` skips TUN, so a `tun` update reaches the VPN only through a restart.
To move a setting onto this path:
1. Add it to Dart `UpdateParams` and `toUpdateParams`.
2. Add it to Go `UpdateParams` in `core/protocol.go` and apply it in `updateConfig`.
3. Reset it in `PatchClashConfig.setupOnly`. If you skip this, every change also reloads the profile. The
`PatchClashConfig.setupOnly` test in `test/models/config_test.dart` fails until its fixture changes the field and
`setupOnly` resets it.
4. Keep writing it in `_makeRealProfileTask`, so a full apply hands the Core the same value.
Prefer update whenever Go has a setter for the setting, because an apply reloads the whole config.
## Apply
`CoreManager` answers each change of `setupPatchProvider` with `applyProfileDebounce(silence: true)`. That call
rebuilds the profile YAML and hands it to Go's `applyConfig` through `setupConfig`. The provider holds two values:
- `PatchClashConfig.setupOnly`: the config with every field that update carries reset to its default.
- `NetworkProps.appendSystemDns`.
A change to anything else in `PatchClashConfig` therefore reaches it. Today that is the DNS override (`dns`,
`dnsOverrideKeys`), `hosts`, the extra inbound ports (`port`, `socks-port`, `redir-port`, `tproxy-port`), the dialer
options (`keep-alive-*`, `disable-keep-alive`, `interface-name`, its mode, `routing-mark`), `geodata-loader` and
`global-ua`. A new `PatchClashConfig` field written by `_makeRealProfileTask` lands here without further work. A value
the profile build reads from another settings object has to be added to `setupPatchProvider`.
An apply without `force` sends nothing when the md5 of the generated YAML has not changed. A value that only
`SetupParams` carries never changes the YAML, so this path does not deliver it. `testUrl` is that case: Go's default
health-check URL follows it only when some other change or a `force` apply reaches the Core.
Profile content does not go through `setupPatchProvider`. `setupStateProvider` reads rules, groups and scripts from the
database once instead of watching them. Content is applied explicitly by whatever changes it:
- Profile editors call `autoApplyProfile` when they close.
- Subscription updates, `setProfileAndAutoApply`, scripts and app-level providers call `applyProfileDebounce`.
- A profile switch runs `fullSetup`.
- A start applies again with `force`.
## Restart
`VpnService.handleStart` builds the tunnel once from `VpnOptions` and never reads them again.
`ServiceController.start` uses `enable` to choose between `VpnService` and `ProxyService`.
`vpnOptionsProvider` derives the options the service would start with now. `runningVpnOptionsProvider` records what
the last listener start handed it. The route list is subtracted in an isolate by `vpnRouteAddressProvider`, which
reruns only when the route lists or `bypassPrivateRoute` change, so `vpnOptionsProvider` is async: a start awaits it
before recording, the Android Core awaits it before its first `syncState`, and the shared state keeps the last options
while new routes compute rather than handing the service an empty list. `SetupAction` owns every start and stop: it
sets the record on a start and clears it on a stop, on-demand SSID suspension included.
`VpnManager` shows the notifier whenever a change leaves `VpnOptions.effective` different from the recorded options.
Repeated changes refresh the same notifier instead of stacking new ones. Changing a setting back to the running value
asks for nothing. The tap runs `SetupAction.restartVpn`: a stop, then a start, and only while a run is up.
| `VpnOptions` field | Taken from | The service reads it |
|------------------------|---------------------------------------------------------------|----------------------------------------|
| `enable` | `VpnProps.enable`, off in safe mode | always |
| `stack` | `Tun.stack` | with the VPN |
| `ipv6`, `dnsHijacking` | `VpnProps` | with the VPN |
| `allowBypass` | `VpnProps` | with the VPN |
| `accessControlProps` | `VpnProps.accessControlProps` | `enable`, `mode` and that mode's list |
| `systemProxy` | `VpnProps.systemProxy`, off under local auth or safe mode | with the VPN |
| `port` | `PatchClashConfig.mixedPort` | with `systemProxy` |
| `bypassDomain` | `NetworkProps.bypassDomain` | with `systemProxy` |
| `routeAddress` | `Tun.routeAddress` and `bypassPrivateRoute` | with the VPN |
| `mtu` | `Tun` | with the VPN |
| `congestionController` | `Tun` | with the VPN |
The service never reads the access list's sort and filter fields. `effective` resets them, along with every field
the table does not mark as read, so changing them does not ask for a restart.
To add a VPN option:
1. Add it to Dart and Kotlin `VpnOptions` and to `vpnOptionsProvider`.
2. If the service reads it only under a condition, put that condition in `VpnOptions.effective`, which mirrors
`ServiceController.start` and `VpnService.handleStart`.
The notifier stays off while nothing runs, during on-demand suspension, in safe mode, and on desktop, where
`VpnManager` is not mounted. When Flutter reattaches to a service that is already running, it records the current
options, because the service does not report the options it was started with.
## One Setting, Several Paths
Each path watches its own provider, so a single setting can take more than one of them:
- `mixedPort`: update moves the Core's listener; under the VPN system proxy, the restart notice follows.
- TUN fields and `bypassPrivateRoute`: update rebuilds the desktop TUN. On Android the update reaches no TUN, and the
restart notice carries the change.
- Local authentication: update applies it to the inbounds. It also switches the VPN system proxy, which needs a
restart.
## Choosing The Path
For a new or moved setting, answer each question. More than one answer can be yes.
1. Does the Android service read it when it starts? Then it belongs in `VpnOptions` and takes the restart path.
2. Does the Core read it, and can Go change it in place? Then it belongs in `UpdateParams` and takes the update path.
3. Does the Core read it only when it parses a config? Then write it into the generated config, and it takes the
apply path.
4. Does only Flutter read it? Then it takes no path.
Tests that hold these rules:
- `test/models/config_test.dart` (`PatchClashConfig.setupOnly`).
- `test/models/core_test.dart` (`VpnOptions.effective`).
- `test/providers/state_derived_test.dart` (the setup patch).
- `test/manager/core_manager_test.dart` (update against apply).
- `test/manager/vpn_manager_test.dart` (the restart notice).
- `test/providers/setup_action_test.dart` (running options, suspension, `restartVpn`).
+3 -1
View File
@@ -13,9 +13,11 @@ build hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile b
## 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
JSON envelope and `core/message.go` queue rules, gomobile callback rules, `BootGuard`, tray, and SSID rules, and every
lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,
optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).
- `.agents/setting-changes.md`: a setting the Core or the Android VPN reads is patched live, re-applied with the
profile, or left to a restart the user confirms; it says which, and what adding a setting to each path takes.
- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,
Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,
the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.
+3 -2
View File
@@ -35,8 +35,9 @@ Do not use this for README translation sync or manual edits to generated localiz
```
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.
lint test checks that every locale carries the same keys and that no `Intl.message` builds a key at runtime; labels
for enum values go through the switches in `lib/common/l10n_labels.dart`, as the ARB section of `.agents/rules.md`
explains.
7. Re-run the Han-text scan for targeted strings.
## Pitfalls
+20 -3
View File
@@ -13,8 +13,9 @@ 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.
look the rest of the app has: Material You from `material_ui` as the base, with Apple's interaction conventions
(continuous corners, sheets that snap between detents, a floating tab bar) built on Material widgets rather than
Cupertino ones. Do not introduce a visual system for one screen or rewrite a layout the request does not need.
- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,
and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not
await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).
@@ -27,11 +28,27 @@ dialogs, and interaction behavior.
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
keep scrims from stacking.
- A sheet's size belongs to its route, never to its content or call site: a bottom sheet rests at
`SheetProps.detents`, and a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from
the window width (`lib/widgets/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`). Do not wrap a
sheet's content in a fixed width or height to resize one sheet.
- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)
rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in
`.agents/architecture.md` has the reasons.
- A page full of images loads them once its route has settled and builds no rows past the viewport until then:
`RouteSettledMixin` and `arrivalScrollCacheExtent`, described under Page Arrival in `.agents/architecture.md`.
- A page that waits on its data as it arrives shows its illustration alone (`NullStatus` without a label, in a
`NullStatusSwitcher`) until all of it is ready, never a skeleton screen; Page Arrival in `.agents/architecture.md`
has the rule.
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
- A row's secondary actions open from a context menu, never a trailing more button; a list whose rows have one
reorders in a sort mode, any other by a long press on the row. Row Actions And Sort Mode in `.agents/architecture.md`
has the rule.
- Quick edit belongs only on a page that edits a whole list or section, or one isolated item; never on one that edits a
part of something other settings also write. Quick Edit in `.agents/architecture.md` has the rule.
- A page or sheet opens on the navigator of the context it comes from, never on the root navigator unless Where A
Route Opens in `.agents/architecture.md` allows it.
- User-facing text goes through ARB; use `localization` when text changes are non-trivial.
- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a
minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in
@@ -43,7 +60,7 @@ dialogs, and interaction behavior.
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
The scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 dp tall
reads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.
| Token | Value | Shortest side | Use |
+5
View File
@@ -15,6 +15,8 @@ Read these only when the task touches their area:
- [.agents/architecture.md](.agents/architecture.md): core integration, providers, database, managers, build system, and
local plugins.
- [.agents/setting-changes.md](.agents/setting-changes.md): whether a changed setting is patched live, re-applies the
profile, or asks for an Android VPN restart.
- [.agents/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.
@@ -57,6 +59,9 @@ Read these only when the task touches their area:
never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or
`InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of
[.agents/rules.md](.agents/rules.md).
- Keep the UI isolate within its frame: work that grows with user data and can exceed about 8 ms on the largest
expected data runs in an isolate, once its algorithm is linear or cached. See the UI Isolate Work section of
[.agents/rules.md](.agents/rules.md).
- Follow `lint_options.yaml` (included by every `analysis_options.yaml`), especially single quotes, trailing commas, `child:` last, no `print()`, const/final
preferences, and declared return types.
- Before reporting a change done, run `flutter pub get`, `flutter analyze --no-fatal-infos`, and the `flutter test`
+4 -3
View File
@@ -31,7 +31,8 @@ A multi-platform proxy client based on ClashMeta. Simple to use, open source and
- **Profiles** from a subscription link or a file, with a built-in editor, override scripts, and custom rules,
proxies and proxy groups.
- **Live views** of connections, requests, DNS queries and logs.
- **Material You design** with dynamic color, light and dark themes, and layouts that adapt from phones to desktops.
- **Material You design with Apple-style interaction**: dynamic color, light and dark themes, continuous corners,
sheets that snap between heights, and layouts that adapt from phones to desktops.
- **Backup and restore** through WebDAV or a local file.
- **Platform touches**: a Quick Settings tile, per-app proxy and Android TV support on Android; a tray menu and
global hotkeys on the desktop.
@@ -97,8 +98,8 @@ adb shell am start -a com.follow.clash.action.TOGGLE
## Build from source
You need [Flutter](https://docs.flutter.dev/get-started/install) 3.47 (release builds use 3.47.4),
[Go](https://go.dev/dl/) 1.26 and [Rust](https://rustup.rs/) installed through rustup. Each platform builds on its own
You need [Flutter](https://docs.flutter.dev/get-started/install) 3.47 (release builds use 3.47.6),
[Go](https://go.dev/dl/) 1.27 and [Rust](https://rustup.rs/) installed through rustup. Each platform builds on its own
host, except Android, which builds anywhere.
```bash
+2 -2
View File
@@ -30,7 +30,7 @@
- **mihomo(Clash.Meta)内核**:规则分流、代理组、延迟测试、系统代理与 TUN 模式。
- **配置管理**:通过订阅链接或文件导入配置,内置编辑器,支持覆写脚本以及自定义规则、代理和代理组。
- **实时查看**连接、请求、DNS 查询和日志。
- **Material You 设计**:支持动态取色、浅色与深色主题,布局随屏幕从手机到桌面自适应。
- **Material You 设计,融合 Apple 式交互**:支持动态取色、浅色与深色主题,连续曲率圆角,弹出面板可在几档高度间吸附,布局随屏幕从手机到桌面自适应。
- **备份与恢复**:通过 WebDAV 或本地文件。
- **平台细节**:Android 上有快捷设置磁贴、分应用代理和 Android TV 支持;桌面端有托盘菜单和全局快捷键。
- **开源无广告**,以 GPL-3.0 协议发布。
@@ -92,7 +92,7 @@ adb shell am start -a com.follow.clash.action.TOGGLE
## 从源码构建
需要 [Flutter](https://docs.flutter.dev/get-started/install) 3.47(正式版构建使用 3.47.4)、[Go](https://go.dev/dl/) 1.26,
需要 [Flutter](https://docs.flutter.dev/get-started/install) 3.47(正式版构建使用 3.47.6)、[Go](https://go.dev/dl/) 1.27,
以及通过 rustup 安装的 [Rust](https://rustup.rs/)。桌面端需要在对应系统上构建,Android 在任意系统上都能构建。
```bash
+12 -11
View File
@@ -118,17 +118,18 @@ user32 without it rather than reporting an effect that draws nothing.
With `titleBarStyle: hidden` on Windows, `WM_NCCALCSIZE` takes the frame
insets from `AdjustWindowRectExForDpi` and keeps the client rect at the
window's top edge, plus the frame height when maximized so the taskbar stays
uncovered. Windows 11 draws the top border into a 1 px non-client strip left
there. Windows 10 draws the whole caption over any strip shorter than one
(Windows Terminal's `NonClientIslandWindow` records the same), so it gets no
strip. Instead, while no accent effect is on, `WindowStyle::Apply` extends the
DWM frame over the whole caption height and `WindowController` keeps a 1 px
row of client area above the Flutter view, paints it black in `WM_PAINT`
(alpha 0 to DWM, which shows the frame's border through it) and answers
`WM_NCHITTEST` with `HTTOP` for it. The plugin therefore positions the view
itself on `WM_SIZE` and after every frame change; the runner's own placement
only runs before `ensureInitialized`. The row is gone while maximized or full
screen, where there is no border to show, and while an accent effect is on.
uncovered. Windows 11 draws the top border into a non-client strip one
physical pixel tall left there. Windows 10 draws the whole caption over any
strip shorter than one (Windows Terminal's `NonClientIslandWindow` records the
same), so it gets no strip. Instead, while no accent effect is on,
`WindowStyle::Apply` extends the DWM frame over the whole caption height and
`WindowController` keeps a row of client area one physical pixel tall above
the Flutter view, paints it black in `WM_PAINT` (alpha 0 to DWM, which shows
the frame's border through it) and answers `WM_NCHITTEST` with `HTTOP` for it.
The plugin therefore positions the view itself on `WM_SIZE` and after every
frame change; the runner's own placement only runs before `ensureInitialized`.
The row is gone while maximized or full screen, where there is no border to
show, and while an accent effect is on.
The Flutter content must paint a translucent background for anything but
`none` to be visible.
+38 -11
View File
@@ -16,6 +16,7 @@
: 'cubic-bezier(0.22, 1, 0.36, 1)';
const COS30 = Math.cos(Math.PI / 6);
let field = null;
let counting = false;
const state = {
lang: root.lang.startsWith('zh') ? 'zh' : 'en',
@@ -238,25 +239,49 @@
}
const stars = $('#stars');
if (release && typeof release.stars === 'number') {
const format = new Intl.NumberFormat(locale(), { notation: 'compact', maximumFractionDigits: 1 });
const format = starFormat();
const compact = format.format(release.stars);
if (stars.hidden) countUp(stars, release.stars, format);
else stars.textContent = compact;
stars.hidden = false;
if (stars.hidden) {
stars.hidden = false;
countUp(stars, release.stars, format);
} else if (!counting) {
stars.textContent = compact;
}
$('#gh-link').setAttribute('aria-label', `GitHub, ${t('stars', compact)}`);
}
}
function starFormat() {
return new Intl.NumberFormat(locale(), { notation: 'compact', maximumFractionDigits: 1 });
}
// Counts in the final figure's own unit and precision, inside a box as wide as the final figure, so neither a new
// digit nor a switch to a compact suffix moves the nav while it runs.
function countUp(node, value, format) {
if (reducedMotion.matches) {
node.textContent = format.format(value);
return;
}
const final = format.format(value);
node.textContent = final;
if (reducedMotion.matches) return;
const parts = format.formatToParts(value);
const number = parts.filter((part) => ['integer', 'group', 'decimal', 'fraction'].includes(part.type));
const text = (list) => list.map((part) => part.value).join('');
const prefix = text(parts.slice(0, parts.indexOf(number[0])));
const suffix = text(parts.slice(parts.indexOf(number[number.length - 1]) + 1));
const decimal = parts.find((part) => part.type === 'decimal')?.value ?? '.';
const digits = parts.find((part) => part.type === 'fraction')?.value.length ?? 0;
const shown = Number(text(number.filter((part) => part.type !== 'group')).replace(decimal, '.'));
node.style.minWidth = `${node.getBoundingClientRect().width}px`;
counting = true;
const start = performance.now();
const step = (now) => {
const progress = Math.min((now - start) / 1100, 1);
node.textContent = format.format(Math.round(value * (1 - (1 - progress) ** 4)));
if (progress < 1) requestAnimationFrame(step);
const progress = Math.min(Math.max((now - start) / 1100, 0), 1);
const current = (shown * (1 - (1 - progress) ** 4)).toFixed(digits).replace('.', decimal);
node.textContent = progress < 1 ? prefix + current + suffix : final;
if (progress < 1) {
requestAnimationFrame(step);
} else {
node.style.minWidth = '';
counting = false;
}
};
step(start);
}
@@ -1014,6 +1039,8 @@
wireEvents();
wireGlyph();
wireField();
const stars = $('#stars');
if (stars.dataset.count && !stars.hidden) countUp(stars, Number(stars.dataset.count), starFormat());
stageReveal(document.querySelectorAll('.section .rail, .dl, #facts, .foot'));
const [device] = await Promise.all([detectDevice(), loadData()]);
+1
View File
@@ -379,6 +379,7 @@ button {
}
.stars {
text-align: end;
font-variant-numeric: tabular-nums;
}
+11
View File
@@ -88,6 +88,17 @@ function render(page) {
(_, a, b, key) => `${a}${text(key)}${b}`,
);
// With the count in the page, the GitHub link has its final width from the first paint instead of growing when
// release.json arrives.
if (typeof release?.stars === 'number') {
const stars = new Intl.NumberFormat(page.locale, { notation: 'compact', maximumFractionDigits: 1 });
html = replaceOnce(
html,
/<span id="stars" class="stars" hidden><\/span>/,
`<span id="stars" class="stars" data-count="${release.stars}">${escape(stars.format(release.stars))}</span>`,
);
}
if (versions.length) {
const meta = escape(changelogMeta(versions, page.lang));
html = replaceOnce(html, /(<p class="section-sub" id="cl-meta">)(<\/p>)/, (_, open, close) => open + meta + close);