diff --git a/.agents/architecture.md b/.agents/architecture.md index 4da77b31..969c68c6 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -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-flclash.` 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 `unpackCoreJniLibs` 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:` 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 diff --git a/.agents/commands.md b/.agents/commands.md index 445ae21d..a8acc221 100644 --- a/.agents/commands.md +++ b/.agents/commands.md @@ -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. diff --git a/.agents/flutter-upgrade.md b/.agents/flutter-upgrade.md index 1a3e321c..39a5f654 100644 --- a/.agents/flutter-upgrade.md +++ b/.agents/flutter-upgrade.md @@ -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 diff --git a/.agents/project.md b/.agents/project.md index c68f6178..b3e88ca4 100644 --- a/.agents/project.md +++ b/.agents/project.md @@ -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. diff --git a/.agents/rules.md b/.agents/rules.md index f0e87e39..c05b6a22 100644 --- a/.agents/rules.md +++ b/.agents/rules.md @@ -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 [(scope)][!]: @@ -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 ` on the commit that +introduced it, or `git show :` 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()` -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()`. 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. diff --git a/.agents/setting-changes.md b/.agents/setting-changes.md new file mode 100644 index 00000000..0988ba57 --- /dev/null +++ b/.agents/setting-changes.md @@ -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`). diff --git a/.agents/skills/core-platform/SKILL.md b/.agents/skills/core-platform/SKILL.md index 826c4851..c93c6841 100644 --- a/.agents/skills/core-platform/SKILL.md +++ b/.agents/skills/core-platform/SKILL.md @@ -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. diff --git a/.agents/skills/localization/SKILL.md b/.agents/skills/localization/SKILL.md index 2178edd0..d0666ec0 100644 --- a/.agents/skills/localization/SKILL.md +++ b/.agents/skills/localization/SKILL.md @@ -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 diff --git a/.agents/skills/ui-work/SKILL.md b/.agents/skills/ui-work/SKILL.md index 58718d09..9fe451be 100644 --- a/.agents/skills/ui-work/SKILL.md +++ b/.agents/skills/ui-work/SKILL.md @@ -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 | diff --git a/AGENTS.md b/AGENTS.md index f93870c1..598a2d45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/README.md b/README.md index 07ce79ed..963879f5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_zh_CN.md b/README_zh_CN.md index 7c6ce1da..85beddf0 100644 --- a/README_zh_CN.md +++ b/README_zh_CN.md @@ -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 diff --git a/plugins/window/README.md b/plugins/window/README.md index 13f3adf1..0124cef3 100644 --- a/plugins/window/README.md +++ b/plugins/window/README.md @@ -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. diff --git a/site/assets/app.js b/site/assets/app.js index f2483070..29dab67f 100644 --- a/site/assets/app.js +++ b/site/assets/app.js @@ -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()]); diff --git a/site/assets/style.css b/site/assets/style.css index 829bbca9..ce33526e 100644 --- a/site/assets/style.css +++ b/site/assets/style.css @@ -379,6 +379,7 @@ button { } .stars { + text-align: end; font-variant-numeric: tabular-nums; } diff --git a/tool/render_site.mjs b/tool/render_site.mjs index c9ed8458..1ef6c5c3 100644 --- a/tool/render_site.mjs +++ b/tool/render_site.mjs @@ -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, + /