docs(agents): document custom profiles and setting changes
This commit is contained in:
+409
-119
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()]);
|
||||
|
||||
@@ -379,6 +379,7 @@ button {
|
||||
}
|
||||
|
||||
.stars {
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user