docs(agents): correct stale facts and sharpen skill triggers
This commit is contained in:
@@ -5,11 +5,11 @@ Use the smallest durable surface that matches the job.
|
||||
## Surfaces
|
||||
|
||||
- `AGENTS.md`: repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
|
||||
- `CLAUDE.md`: the `@AGENTS.md` import and nothing that is not Claude-only. Claude Code loads `CLAUDE.md` into the system prompt at startup; the import makes `AGENTS.md` arrive with it. Without it, `AGENTS.md` reaches the session only as a tool result some agent chose to fetch, which is the weakest position in context and the first thing dropped when the conversation is summarized. Do not let rules accumulate in this file.
|
||||
- No `CLAUDE.md`: Claude Code v2.1.277+ loads `AGENTS.md` at startup only while no `CLAUDE.md`, `.claude/CLAUDE.md`, or `CLAUDE.local.md` sits in the working directory or above it, so adding one silently replaces `AGENTS.md` for Claude. Sessions that cannot load `AGENTS.md` directly (third-party providers, telemetry disabled) need an uncommitted `CLAUDE.local.md` containing `@AGENTS.md`. The one nested `CLAUDE.md`, in `plugins/rust_api/`, imports the root `AGENTS.md` for the same reason.
|
||||
- `.agents/*.md`: human- and agent-readable reference docs linked from `AGENTS.md`. Use these for detailed project context, commands, architecture, and conventions.
|
||||
- `.agents/skills/*/SKILL.md`: repo-scoped Codex skills. Use these for repeatable workflows that should be discoverable by name and description.
|
||||
- `.agents/skills/*/SKILL.md`: repo skills shared by Codex and Claude Code. Use these for repeatable workflows that should be discoverable by name and description; `.claude/skills/` holds one symlink per skill so Claude Code finds the same files.
|
||||
- `.codex/config.toml`: trusted project Codex settings such as MCP, hooks, sandbox, approval, or model defaults.
|
||||
- `.codex/rules/*.rules`: command permission rules for running commands outside the sandbox. Do not mix these with coding conventions in `.agents/rules.md`.
|
||||
- `.codex/rules/*.rules`: command permission rules for running commands outside the sandbox. None exist yet. Do not mix these with coding conventions in `.agents/rules.md`.
|
||||
- `.codex/hooks.json` or `.codex/config.toml` hooks: lifecycle automation such as prompt checks, command validation, or final verification gates.
|
||||
|
||||
## Placement Rules
|
||||
@@ -19,7 +19,7 @@ Use the smallest durable surface that matches the job.
|
||||
- Put reusable task workflows in `.agents/skills/<skill-name>/SKILL.md`.
|
||||
- Put mechanical enforcement in linters, tests, hooks, or `.codex/rules`; do not rely on prose when tooling can enforce the rule.
|
||||
- Split a rule into the part that can be counted and the part that cannot, and enforce them differently. A rule resting entirely on judgment gets decided at the keyboard, at the moment the exception looks most justified; the comment policy is the worked example, where "is this comment useful" stays prose and review while "are these comments too frequent" became a measured gate. Find the countable half before writing more prose.
|
||||
- Restate a rule verbatim in every context that must obey it, and prefer a runnable check to a description of the rule. A gate an agent trips before reporting done outperforms a paragraph asking it to have been careful.
|
||||
- Restate the one-line form of a rule in every context that must obey it, and keep its explanation in one `.agents/*.md` file that the others link to. Prefer a runnable check to either: a gate an agent trips before reporting done outperforms a paragraph asking it to have been careful.
|
||||
- State a gate's real force. Enforcement differs per tool, and a rule that claims uniform coverage it does not have is worse than one that names the gap.
|
||||
- Keep user-specific preferences out of the repository. They belong in user-level Codex configuration or user-level skills.
|
||||
|
||||
|
||||
+376
-80
@@ -17,10 +17,10 @@ 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 now owns the transport state, RPC correlation, process ownership, and lifecycle convergence above those primitives.
|
||||
on Windows. Dart owns the transport state, RPC correlation, process ownership, and lifecycle convergence above those
|
||||
primitives.
|
||||
- `lib/core/service.dart` (`CoreService`) is the composition root. It wires the IPC transport, launcher selection,
|
||||
lifecycle controller, RPC client, and crash-event bridge; it is no longer the whole desktop implementation by itself.
|
||||
lifecycle controller, RPC client, and crash-event bridge, and holds no lifecycle logic of its own.
|
||||
- `lib/core/desktop/transport.dart` converts native IPC frames into ready, connected, disconnected, failed, and data
|
||||
events. A replaceable binding keeps RPC subscriptions stable when a failed or stale transport must be rebuilt.
|
||||
- `lib/core/desktop/rpc_client.dart` owns request IDs and pending completers, waits up to 10 seconds for a connection,
|
||||
@@ -36,10 +36,45 @@ Key Go core files:
|
||||
|
||||
- `core/hub.go`: handler functions.
|
||||
- `core/method.go`: MethodChannel-style method-call dispatch and response envelopes.
|
||||
- `core/message.go`: non-blocking priority/bulk event queues and bounded message batching.
|
||||
- `core/message.go`: non-blocking state/priority/bulk event queues and bounded message batching.
|
||||
- `core/lib.go`: CGO exports.
|
||||
- `core/server.go`: desktop socket/named-pipe client and framed message forwarding.
|
||||
|
||||
## DNS Override
|
||||
|
||||
`PatchClashConfig.dns` holds a full `Dns` model, but only the keys listed in `dnsOverrideKeys` reach a profile.
|
||||
`DnsOverrideKey` names one Clash key each, with `fallback-filter.*` entries for the nested filter, and covers every
|
||||
yaml key of mihomo's `RawDNS` and `RawFallbackFilter`; `test/models/dns_override_test.dart` reads
|
||||
`core/Clash.Meta/config/config.go` and fails when a core bump adds or drops one. `Dns.overrideJson` builds the
|
||||
fragment in model order and `mergeDnsOverride` merges it one level deep, so a partial fallback filter keeps the
|
||||
profile's other filter keys. `_makeRealProfileTask` applies the fragment when `overrideDns` is on; for a profile whose
|
||||
`dns.enable` is not true it first fills `defaultDns` for `baselineDnsOverrideKeys` and then applies the fragment
|
||||
whatever the toggle says. The DNS page's quick edit opens `overrideYaml` in an `EditorPage` and applies it on the way
|
||||
back through `applyOverrideYaml`, which rebuilds the key set from the keys the document names and rejects unknown keys
|
||||
or bad values; a rejected document can only be discarded or fixed. The page has no reset, because there is nothing to
|
||||
reset to.
|
||||
|
||||
A fresh config overrides nothing: `dnsOverrideKeys` starts empty and the profile's own section stands until an entry
|
||||
is added. `baselineDnsOverrideKeys` is the separate, smaller thing the core is never without — the `enable`,
|
||||
`enhanced-mode: fake-ip` and `nameserver` given to a profile that brings no enabled DNS section of its own, so the
|
||||
core always resolves. Every other `Dns` field starts at mihomo's own default, which `UnmarshalRawConfig` starts from,
|
||||
so adding an entry changes nothing until it is edited; `listen` and `fake-ip-range6`, which mihomo leaves off, start
|
||||
from a working value instead. A config saved before the key list existed overrode the whole section as the model held
|
||||
it then, so `PatchClashConfig.fromJson` gives it those twenty keys rather than the empty set.
|
||||
|
||||
## NTP Override
|
||||
|
||||
`PatchClashConfig.ntp` mirrors the DNS shape: a full `Ntp` model of which only the keys in `ntpOverrideKeys` reach a
|
||||
profile, an empty set on a fresh config, and one `NtpOverrideKey` per yaml key of mihomo's `RawNTP`;
|
||||
`test/models/ntp_override_test.dart` reads `core/Clash.Meta/config/config.go` and fails when a core bump adds, drops
|
||||
or redefaults one. The section is flat, so `_makeRealProfileTask` spreads the fragment over the profile's own `ntp`
|
||||
map instead of the one-level merge DNS needs, and writes nothing at all while `overrideNtp` is off. `interval` is
|
||||
minutes, which is what `ReCreateNTPService` multiplies. The page carries the same add sheet and quick edit as DNS.
|
||||
|
||||
Safe mode forces `write-to-system` off on whatever `ntp` section the profile carries, because setting the system clock
|
||||
is a host change; mihomo's own `loadConfig` already drops it on Android, where the seccomp policy kills the process
|
||||
for `settimeofday`.
|
||||
|
||||
## Listener Exposure
|
||||
|
||||
`_makeRealProfileTask` in `lib/common/task.dart` writes `mixed-port`, `allow-lan`, and `external-controller` into every
|
||||
@@ -49,7 +84,7 @@ generated profile. With `allow-lan` off, mihomo's `genAddr` binds each listener
|
||||
The loopback listener takes local connections without authentication by default: mandatory credentials would lock out
|
||||
every external local consumer a proxy client exists for. The binding only guarantees traffic originates on the device;
|
||||
it does not isolate programs already running there, so on Android any app holding `INTERNET` can use the proxy and learn
|
||||
the outbound IP (#1934). The opt-in answer is the local authentication setting (`NetworkProps.localAuth`), which spans
|
||||
the outbound IP (#1934). The opt-in answer is the local authentication setting (`NetworkProps.authentication`), which spans
|
||||
four paths that must stay in sync: `_makeRealProfileTask` writes the credentials into `authentication` and force-clears
|
||||
`skip-auth-prefixes` so a profile cannot silently exempt loopback; `UpdateParams.authentication` applies the same list
|
||||
to a running core; `FlClashHttpOverrides` sends the credentials with the app's own proxied requests; and `sharedState`
|
||||
@@ -76,9 +111,9 @@ callers must not try to reuse a closed platform implementation.
|
||||
|
||||
- `startCore()` publishes `connecting`, starts the platform Core, publishes `connected`, then initializes Core state. A
|
||||
startup error publishes `disconnected` and displays the error.
|
||||
- `restartCore()` coalesces overlapping callers behind one worker. `_requestedRestartRevision` records newer requests,
|
||||
while `_latestExplicitStart` retains the newest requested post-restart running intent. After the lifecycle restart and
|
||||
`initCore()`, the worker reapplies profile/running state until it has consumed the latest revision.
|
||||
- `restartCore()` coalesces overlapping callers behind one worker. `_requestedRestartRevision` records newer requests.
|
||||
After the lifecycle restart, the worker reapplies running state or the profile, rereading `isStartProvider` on every
|
||||
pass, until it has consumed the latest revision, so the newest start intent wins.
|
||||
- The provider is an orchestration and presentation layer, not a process owner. Platform lifecycle code remains responsible
|
||||
for determining whether a Core process/service is actually running.
|
||||
|
||||
@@ -156,12 +191,26 @@ remain strings.
|
||||
|
||||
Go event delivery is intentionally non-blocking:
|
||||
|
||||
- State-bearing events such as delay, loaded-provider, and geo-update use a 256-entry priority queue. Desktop process
|
||||
crashes are generated locally by `CoreService` from lifecycle failures rather than sent through the Go queue.
|
||||
- High-volume log and request/connection events use a separate 256-entry bulk queue, so bulk floods cannot evict state.
|
||||
- A full queue evicts only its own oldest event and retries the newest event; Core work never blocks on event delivery.
|
||||
- Loaded-provider, geo-update, and route-changed events use a state queue that never evicts; the rules for it are under
|
||||
Core API Safety in `.agents/rules.md`. Every other event except the bulk kinds, delay included, uses a 256-entry
|
||||
priority queue. Desktop process crashes are generated locally by `CoreService` from lifecycle failures rather than
|
||||
sent through the Go queue.
|
||||
- High-volume log, request/connection, and DNS query events use a separate 256-entry bulk queue, so bulk floods cannot
|
||||
evict state.
|
||||
- A full priority or bulk queue evicts only its own oldest event and retries the newest; DNS events never evict. Core
|
||||
work never blocks on event delivery.
|
||||
- The batcher flushes at 32 messages or every 16 milliseconds. Priority events are preferred, but one bulk opportunity is
|
||||
guaranteed after eight priority messages to prevent starvation.
|
||||
- `sendMessage` drops an event before it is queued when `hasEventListener` is false. That only happens on Android, after
|
||||
the Flutter engine detaches while the service keeps the Core running; request and DNS events would otherwise be
|
||||
encoded per connection and per query for nobody.
|
||||
|
||||
DNS query events come from the Clash.Meta fork's `dns/patch.go` and are raised where mihomo writes its `[DNS]` debug
|
||||
log, so the DNS page lists the same resolutions: one event per cache hit, and one per upstream exchange however many
|
||||
callers singleflight merged into it, carrying the upstream whose answer was used. Fake-IP and hosts answers never reach
|
||||
a resolver and are not recorded. Queries from apps arrive with the `DNSContext` that `Service.ServeMsg` created and
|
||||
report `app`; other initiators come from `resolver.WithInitiator` at the call site (rule matching, DIRECT, proxy
|
||||
server dialing), and unlabeled queries report `other`.
|
||||
|
||||
Desktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate
|
||||
listener exceptions so one faulty observer does not prevent the remaining events/listeners from running.
|
||||
@@ -179,20 +228,126 @@ surface. It is shown only outside dashboard edit mode and only when `coreLib ==
|
||||
- Taps during the display hold or while the provider is genuinely connecting are inert. Connected/disconnected taps show
|
||||
the appropriate confirmation and delegate restart to `CoreAction`; the widget never starts Core directly.
|
||||
|
||||
Proxy delay testing follows the same failure-safe UI rule. `proxyDelayTest()` records an in-progress zero delay, writes the
|
||||
real result on success, and logs plus records `-1` on exceptions. `DelayTestButton` reverses its animation in `finally`, so
|
||||
an RPC failure cannot leave the control permanently spinning.
|
||||
Proxy delay testing follows the same failure-safe UI rule. `ProxiesAction._runDelayTests` marks its keys in
|
||||
`pendingDelayTestsProvider` and releases them in `finally`, and a test the Core does not answer leaves the last
|
||||
measurement in place; the delay-test bullets under Core API Safety in `.agents/rules.md` give the reasons. A group's delay
|
||||
test button, the tab page's action and the list page's group header alike, spins while `delayTestingGroupsProvider`
|
||||
holds the group; `delayTestPageGroup` marks it, ignores a second request for the same group, and clears it in `finally`,
|
||||
so an RPC failure cannot leave the button spinning and remounting a header does not lose the state.
|
||||
|
||||
## Route Consistency
|
||||
|
||||
A probe result on the dashboard — the exit IP, a node's exit IP, a service check — either describes the route in
|
||||
force or is being asked again. The full design is `docs/plans/probe-route-consistency.md`; the mechanism has three
|
||||
parts.
|
||||
|
||||
- The Core is the source of truth for the route. `core/route.go` keeps a structural epoch (advanced by a config
|
||||
apply, a mode or IPv6 switch, and any proxy provider whose version changed) and a `picks` map from each Selector,
|
||||
URLTest and Fallback group to what it resolves to right now, versioned on every change. `handleChangeProxy`
|
||||
re-reads the picks under `selectMu` and answers `ChangeProxyResult{changed}`, so the connection reset only runs when
|
||||
the pick moved. A health check moving a URLTest pick has no mihomo hook, so the picks are polled every second while
|
||||
the host watches (`watchRoute(true)`) and the listeners are up. Every probe answer carries the counters it started
|
||||
under; `routeChanged` publishes the state whenever it differs from the last read.
|
||||
- `routeTrackerProvider` in `lib/providers/route_state.dart` merges that with the host's side: `proxied` (running and
|
||||
not suspended), a host epoch that a connectivity event moves, and `synced`, which is false until `watchRoute` has
|
||||
returned a snapshot and again after a Core disconnect. Nothing is fresh while unsynced, so a cache never judges
|
||||
answers against counters it made up. The tracker subscribes when the first target is watched and releases the Core
|
||||
when the last one goes; an answer newer than the state means an event was evicted, and the cache asks for a resync.
|
||||
It also releases the Core while the app is hidden and reads it again on return, keeping `synced`: the Core and its
|
||||
counters are the same ones, so the fresh snapshot invalidates only what moved. On Android the Core drops the watch
|
||||
itself when the event listener is cleared, because an engine that goes away never sends `watchRoute(false)`.
|
||||
- `RoutedProbe` in `lib/providers/routed_probe.dart` is the one cache both `OutboundIpProbe` and `ServiceStatus` mix
|
||||
in. A widget `watch`es the target it shows and `unwatch`es on the way out; only watched targets are probed, one
|
||||
request in flight per target. An answer is stamped with the Core's counters and the host state at launch, and
|
||||
`RouteState.isFresh` walks its chain against the picks, so a selection change only invalidates results that went
|
||||
through the changed group while an epoch change invalidates everything. A stale watched entry is asked again at
|
||||
once; an unwatched one is dropped. Nothing launches while the app is hidden: what falls due waits for it to be shown.
|
||||
Failures wait for `retry`, `refresh`, or the app resuming (`markResumed`), and
|
||||
a service check that timed out is a failure that still shows its status. `refresh` is the user's own request and
|
||||
runs for unwatched targets too: the service sheet reads the cache and never watches its rows, because one service
|
||||
check is one to four full HTTPS fetches where a delay test is a single empty request.
|
||||
|
||||
`ipQuality(ip)` in `lib/providers/ip_quality.dart` is keyed by the outbound IP found, so a route change reaches it as
|
||||
a new IP and an unchanged IP reuses the result. A lookup races its sources the way the IP check does and takes the
|
||||
first one that determines a type. A success is kept for the session with `ref.keepAlive()`; a failure is not cached,
|
||||
and Riverpod's automatic retry is off because a retry after an HTTP 429 spends the same per-outbound-IP quota again.
|
||||
|
||||
## Icons
|
||||
|
||||
`lib/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and
|
||||
`test/lint/material_icons_test.dart` fails on any `Icons.*` or `AnimatedIcons.*`, so every icon shares one stroke and
|
||||
can animate its shape.
|
||||
A `Glyph` is a list of shapes on a 24-unit grid, stroked at `Glyph.strokeWidth` with round caps and joins and
|
||||
superellipse corners; a curve the primitive shapes cannot draw is a `GlyphPath`, SVG path data traced along the
|
||||
stroke's center. Each shape has a role that decides its filled form, the way SF Symbols and Fluent pair an
|
||||
outlined icon with its filled variant:
|
||||
|
||||
- `body` fills;
|
||||
- `detail` is cut out of the fill, and a `solid` detail such as a dot is cut as an area;
|
||||
- `line` stays a stroke, for parts that belong to no body, like a handle or a link between nodes; it thickens from
|
||||
`Glyph.strokeWidth` toward 2.2 as the glyph fills, so a glyph of lines alone still carries weight in a filled button.
|
||||
|
||||
`GlyphPainter` paints any point between the two forms. Each body fills inward from its outline, reaching half its
|
||||
shorter side, which no point of the body lies beyond, as `fill` reaches 1. Details thin away over the first half and
|
||||
return as cutouts over the second, so a detail is never a stroke and a cutout at once. The glyph is painted opaque into
|
||||
one layer that carries the color's alpha: a translucent color, such as a disabled one, then stays even where strokes
|
||||
cross the fill, and a cutout clears only the glyph's own pixels. `GlyphIcon` shows a glyph at a fixed `fill`, taking
|
||||
its size, color, and fill from `IconTheme` unless it is given its own, as `Icon` does, and mirroring it in right-to-left
|
||||
text when `Glyph.matchTextDirection` is set.
|
||||
`AnimatedGlyph` drives a `GlyphIcon` from its `filled` flag and pops briefly when it fills. A glyph whose shape follows
|
||||
some other state, like the sidebar toggle that tracks the pane's width, is a builder in `AppGlyphs` such as
|
||||
`AppGlyphs.sidebar(progress)`. A new glyph goes in `AppGlyphs` and in the list `test/icons/glyph_painter_test.dart` runs
|
||||
through its checks; it is named for what it means in the app, one glyph per meaning, with SF Symbols and Fluent as the
|
||||
reference for the metaphor. Tests find one with `find.byGlyph` from `test/helpers/glyph_finders.dart`.
|
||||
|
||||
Icons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, `FilledButton`
|
||||
in its tonal and icon forms, and floating action buttons draw their glyph with `fill: 1`, because an outline on a tinted
|
||||
container reads weaker than the container it sits in. A glyph with no body only thickens its lines and still takes
|
||||
`fill: 1`, so the rule holds if the glyph later gains one. `test/lint/filled_button_glyphs_test.dart` checks every
|
||||
`GlyphIcon` written inside such a button; an icon that reaches the button from elsewhere, as through a helper that picks
|
||||
the button variant, gets its fill by hand or from `IconThemeData.fill`. Everywhere else fill marks state only: the
|
||||
selected destination, a pinned window, a lit torch. The phone's floating navigation bar is the one exception: like an iOS
|
||||
tab bar it fills every destination and marks the selected one by colour alone.
|
||||
|
||||
Framework widgets that draw their own icon take it from the theme: the `ActionIconThemeData` in `lib/application.dart`
|
||||
supplies the back and close buttons, with `AppGlyphs.backFor` picking the platform's back glyph, and a `DropdownMenu`
|
||||
is given its chevrons. A framework widget added later that paints a Material icon needs the same treatment.
|
||||
|
||||
Empty states draw no image assets either. `NullStatus` in `lib/widgets/null_status.dart` shows the glyph for its
|
||||
meaning filled on a Material 3 Expressive shape from `material_new_shapes`, with a few accent marks, and enters with
|
||||
one of three choreographies picked at random: the shape spins in, morphs out of a circle, or springs in while the
|
||||
accents orbit into place. The glyph takes `onPrimaryContainer`, not `primary`: the `content` scheme variant can leave
|
||||
`primaryContainer` as light as `primary` in dark mode.
|
||||
|
||||
`CaptionIcon` is the one icon that does not follow the glyph grid: the window caption buttons keep the one pixel,
|
||||
pixel-aligned stroke of Windows' 10-pixel caption glyphs on every platform.
|
||||
|
||||
## App Bar Actions
|
||||
|
||||
`CommonScaffold` keeps at most three buttons in sight in its bar, the overflow menu button included. A page hands it
|
||||
what may fold as data: `primaryAction`, `iconActions`, `menuItems`, and `searchActions` while searching; custom widgets
|
||||
passed through `actions` never fold. Past three, two buttons stay beside the overflow menu: the search button (the clear
|
||||
button while searching) when there is one, then foldable actions, primary action first unless `foldPrimaryAction` puts
|
||||
the icon actions ahead of it;
|
||||
everything else goes into one overflow menu, primary action first, and a folded action that is loading shows as a
|
||||
disabled item. The bar reads as one button group of two beside a single button: the overflow menu button when
|
||||
something folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu. `primaryAction` counts only where the bar holds it, in a bottom sheet and on a phone's home
|
||||
page, whose foot the navigation dock takes; elsewhere it is the page's floating action button. No page adds a
|
||||
floating action button of its own: a secondary action such as selecting every app goes into `menuItems`, and saving
|
||||
an edit page is a check among its `iconActions`. Actions on a multi-selection, such as delete and select all, go into
|
||||
`selectionActions` instead: the bar keeps them in sight as one button group that takes a single slot beside the search
|
||||
or clear button, so they never fold.
|
||||
|
||||
## Settings Rows
|
||||
|
||||
`lib/widgets/config_item.dart` holds the shared settings-row vocabulary: `ConfigToggleItem`, `ConfigOptionsItem`,
|
||||
`ConfigTextItem`, and `ConfigListInputItem`. Each takes a `selector` (a `ProviderListenable`, normally
|
||||
`ConfigTextItem`, and `ConfigListEditItem`. Each takes a `selector` (a `ProviderListenable`, normally
|
||||
`someProvider.select(...)`) and an `onChanged(ref, value)` writer, and watches its own selector so changing one setting
|
||||
rebuilds one row instead of the whole section. Titles and subtitles are `ConfigLabel` callbacks that receive
|
||||
`AppLocalizations`, which keeps literal labels such as `IPv6` and localized labels in the same shape.
|
||||
|
||||
Build settings screens from these directly, or from a file-local helper that binds one provider once — see `_dnsToggle`
|
||||
in `lib/views/config/dns.dart` and `_appSettingToggle` in `lib/views/application_setting.dart`. Declare a named
|
||||
Build settings screens from these directly, or from a file-local helper that binds one provider once — see `_networkToggle`
|
||||
in `lib/views/config/network.dart` and `_appSettingToggle` in `lib/views/config/general.dart`. Declare a named
|
||||
`ConsumerWidget` only when a row is genuinely reused across screens, as `lib/views/config/network.dart` rows are by
|
||||
`lib/views/dashboard/widgets/quick_options.dart`. Rows with bespoke behaviour — a custom dialog, a derived value, or a
|
||||
second provider write — stay hand-written rather than growing extra parameters on the shared items.
|
||||
@@ -215,6 +370,8 @@ Provider files in `lib/providers/`:
|
||||
- `state/overwrite.dart`: custom overwrite validity and the staged group/rule notifiers.
|
||||
- `action.dart`: business logic notifiers, setup, backup, core lifecycle, proxy selection.
|
||||
- `core.dart`: `coreHandlerProvider`, the container-scoped handle on `CoreController`.
|
||||
- `route_state.dart` and `routed_probe.dart`: the route tracker and the probe cache; see Route Consistency above.
|
||||
- `outbound_ip.dart` and `service_status.dart`: the dashboard probes built on them.
|
||||
- `database.dart`: Drift database provider wrappers.
|
||||
|
||||
### Reaching Singletons
|
||||
@@ -244,7 +401,7 @@ itself live in one. Code without a `Ref` reaches providers through
|
||||
`lib/providers/actions/system.dart` — all singletons or platform callbacks with no
|
||||
`Ref` in scope.
|
||||
|
||||
`lib/models/profile.dart` no longer reaches Core. `Profile.saveFile` and
|
||||
`lib/models/profile.dart` does not reach Core. `Profile.saveFile` and
|
||||
`Profile.update` take a `ValidateConfig` callback, and every caller passes
|
||||
`(path) => _core.validateConfig(path)`, keeping the Core handle lazy so a profile
|
||||
path that never validates never resolves the controller.
|
||||
@@ -269,15 +426,15 @@ generated into `lib/providers/generated/`.
|
||||
|
||||
The root navigator key lives in `lib/common/navigator.dart` as `rootNavigatorKey`;
|
||||
`globalState.navigatorKey` is a getter onto it. `lib/common/dialog.dart` reaches
|
||||
the key directly, so the dialog helpers no longer import `lib/state.dart`.
|
||||
the key directly and does not import `lib/state.dart`.
|
||||
|
||||
### Platform Layering
|
||||
|
||||
`lib/common/common.dart` deliberately does not export `tray.dart`, `window.dart`,
|
||||
`launch.dart`, `system_dns.dart`, or `permission.dart`. Those five modules import
|
||||
`tray_manager`, `window_manager`, `launch_at_startup`, and `screen_retriever`;
|
||||
exporting them put those packages in the compile graph of all 132 files that
|
||||
import the barrel for a string helper. Import the specific module instead.
|
||||
`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`,
|
||||
@@ -289,16 +446,16 @@ closure and fails if *any* file in it imports one of those packages. That one is
|
||||
the real invariant — the local rules only stop the shortest path, and every leak
|
||||
found so far arrived through a longer one.
|
||||
|
||||
Four consequences are already in the tree:
|
||||
The shapes that keep the closure small:
|
||||
|
||||
- `System.back` and `System.exit` no longer touch `window`; the window half of
|
||||
- `System.back` and `System.exit` do not touch `window`; the window half of
|
||||
both lives in `SystemAction`.
|
||||
- `KeyboardModifier.toHotKeyModifier()` moved from `lib/enum/enum.dart` to
|
||||
`lib/manager/hotkey_manager.dart`, its only consumer.
|
||||
- Startup orchestration moved off `GlobalState` into `lib/bootstrap.dart`.
|
||||
- `KeyboardModifier.toHotKeyModifier()` lives in
|
||||
`lib/manager/hotkey_manager.dart`, its only consumer, not in `lib/enum/enum.dart`.
|
||||
- Startup orchestration lives in `lib/bootstrap.dart`, not on `GlobalState`.
|
||||
`common/num.dart`, `common/print.dart` and `common/request.dart` import
|
||||
`state.dart` for `theme`, `container` and `packageInfo`/`ua`, so anything
|
||||
`GlobalState` reaches lands in the barrel's closure; the ambient state it now
|
||||
`GlobalState` reaches lands in the barrel's closure; the ambient state it
|
||||
holds reaches nothing platform-specific.
|
||||
- `SystemAction` talks to `WindowPort` and `TrayPort` from
|
||||
`lib/common/app_ports.dart` instead of importing `common/window.dart` and
|
||||
@@ -306,36 +463,27 @@ Four consequences are already in the tree:
|
||||
the real implementations; both stay null in tests, where every call through
|
||||
them is a no-op. A test that needs the real tray assigns `trayPort` itself, as
|
||||
`test/common/tray_menu_test.dart` does.
|
||||
- `lib/providers/providers.dart` re-exports `action.dart`, so `lib/common` and
|
||||
`providers/app.dart` import `providers/state.dart` and `providers/config.dart`
|
||||
directly; the barrel would reach the whole action layer.
|
||||
- A data type in a lower layer never holds the widget that renders it, because
|
||||
that drags the whole view tree into the barrel's closure. The route table is
|
||||
`lib/views/navigation.dart` implementing `NavigationPort`, which
|
||||
`providers/state/navigation.dart` reads through and `bootstrap.init` binds;
|
||||
unbound it yields no items, so a test that renders navigation assigns
|
||||
`navigationPort` itself, as `test/pages/home_test.dart` does. `DashboardWidget`
|
||||
is plain data, since it is persisted in the app settings; the widget mapping
|
||||
lives in `lib/views/dashboard/widget_registry.dart`, the reverse lookup relying
|
||||
on the branches returning canonical consts.
|
||||
|
||||
Narrow the barrel imports too: `lib/providers/providers.dart` re-exports
|
||||
`action.dart`, so importing the providers barrel from `lib/common` or from
|
||||
`providers/app.dart` reaches the whole action layer. Those three now import
|
||||
`providers/state.dart` and `providers/config.dart` directly.
|
||||
|
||||
The same shape appeared twice more, without a platform package involved: a data
|
||||
type in a lower layer holding the widget that renders it, which drags the whole
|
||||
view tree into the barrel's closure.
|
||||
|
||||
- `lib/common/navigation.dart` was a route table building view widgets. It is
|
||||
now `lib/views/navigation.dart` implementing `NavigationPort`, which
|
||||
`providers/state/navigation.dart` reads through and `bootstrap.init` binds.
|
||||
Unbound it yields no items, so a test that renders navigation assigns
|
||||
`navigationPort` itself, as `test/pages/home_test.dart` does.
|
||||
- `DashboardWidget` carried a `GridItem` per value, so `lib/enum/enum.dart`
|
||||
imported the dashboard cards — and `lib/widgets/widgets.dart` with them. The
|
||||
enum is persisted in the app settings, so it is back to plain data; the
|
||||
mapping lives in `lib/views/dashboard/widget_registry.dart`, the reverse
|
||||
lookup relying on the branches returning canonical consts.
|
||||
|
||||
Together those took the closure from 258 files to 187, with nothing under
|
||||
`lib/views` left in it. `test/lint/platform_layering_test.dart` pins that
|
||||
directly: the barrel's closure must contain no `lib/views` file. Data the
|
||||
provider layer needs from the UI layer goes through a port in
|
||||
`lib/common/app_ports.dart` rather than an import in the other direction.
|
||||
`test/lint/platform_layering_test.dart` also pins that the barrel's closure
|
||||
contains no `lib/views` file. Data the provider layer needs from the UI layer
|
||||
goes through a port in `lib/common/app_ports.dart` rather than an import in the
|
||||
other direction.
|
||||
|
||||
### High-Frequency Buffers
|
||||
|
||||
`logsProvider`, `requestsProvider` and `trafficsProvider` hold a `FixedList`
|
||||
`logsProvider`, `requestsProvider`, `dnsQueriesProvider` and `trafficsProvider` hold a `FixedList`
|
||||
(`lib/common/fixed.dart`), which trades a normal copy-on-write for a shared
|
||||
buffer tagged with a generation counter:
|
||||
|
||||
@@ -354,9 +502,35 @@ buffer tagged with a generation counter:
|
||||
`add`/`clear` mutate in place without advancing the generation; use them only on
|
||||
a buffer you own outright (seeding, resets, tests), never on published state.
|
||||
|
||||
### Background Work
|
||||
|
||||
A proxy client runs for days with nobody looking at it, so work that only feeds the UI stops when the UI cannot be
|
||||
seen. There are three levels of gating:
|
||||
|
||||
- `appVisibleProvider` is false while the desktop window is hidden or the Android activity is in the background
|
||||
(`hidden`, `paused`, `detached`). `AppStateManager` writes it. The one-second run-time and traffic ticker in
|
||||
`SetupAction` follows it. The macOS menu bar title is the exception: it shows the traffic while the window is
|
||||
hidden. `RouteTracker` and `RoutedProbe` follow it too, as described under Route Consistency.
|
||||
- `ActivePollingMixin` is stricter for per-widget polls such as connections and memory. It polls only while the app is
|
||||
`resumed` and its home page is the current one.
|
||||
- `_NavigationPage` puts every page except the current one under a disabled `TickerMode`. Home pages are kept alive in
|
||||
the `PageView`, and Flutter does not mute tickers on a kept-alive page off screen. A spinner left on one would
|
||||
otherwise keep scheduling frames.
|
||||
|
||||
A provider's own `ref.listen` delivers nothing while no one listens to that provider. That is the usual state of an
|
||||
action notifier, which is only ever read. A derived provider it listens to is never recomputed, and even a `ref.read`
|
||||
of it returns a stale value. So an action that has to follow a signal listens to the keep-alive provider that holds
|
||||
it, as `SetupAction` does with `appVisibleProvider`, not to a provider derived from that.
|
||||
|
||||
On Android, `SuspendModule` suspends the Core while the screen is off and the device is in Doze. Health checks that
|
||||
run in Doze all fail, so after a suspension the Core re-probes every provider. That re-probe waits for the screen to
|
||||
come on. A Doze maintenance window resumes the Core too, but probing there would spend the window's radio time on
|
||||
results nobody sees.
|
||||
|
||||
## Database
|
||||
|
||||
The app uses Drift/SQLite in `lib/database/`. Current schema version is 2.
|
||||
The app uses Drift/SQLite in `lib/database/`. `schemaVersion` in `lib/database/database.dart` is the migration
|
||||
counter; every schema change bumps it and adds the matching migration step.
|
||||
|
||||
Tables:
|
||||
|
||||
@@ -365,10 +539,19 @@ Tables:
|
||||
- `Rules`
|
||||
- `ProfileRuleLinks` (`profile_rule_mapping`)
|
||||
- `ProxyGroups`
|
||||
- `CustomProxies` (`custom_proxies`)
|
||||
- `IconRecords` (`icon_records`)
|
||||
- `ClashProviders` (`clash_providers`)
|
||||
|
||||
Rule scenes distinguish global added rules, profile added rules, profile custom rules, and disabled links. Rule and proxy-group ordering use fractional indexing.
|
||||
|
||||
App-level providers (`ClashProviders`, plus every profile offered as a proxy provider) and a custom overwrite's own
|
||||
proxies (`CustomProxies`) are soft-disabled through `feature.customProviders` and `feature.customProxies`
|
||||
(`lib/common/feature.dart`), switched on by `--dart-define=FEATURE_CUSTOM_PROVIDERS=true` and
|
||||
`FEATURE_CUSTOM_PROXIES=true`. While off, their entries are hidden, `appProviderNames` and `appProviderLabels` are
|
||||
empty, and `setupState` loads neither, so stored rows stay in the database but never reach a profile. Backups still
|
||||
carry both tables.
|
||||
|
||||
Generated Drift output lives in `lib/database/generated/database.g.dart`. After schema changes, run code generation and add or update focused database tests under `test/database/` when converter or migration behavior changes.
|
||||
|
||||
## Manager Stack
|
||||
@@ -376,7 +559,7 @@ Generated Drift output lives in `lib/database/generated/database.g.dart`. After
|
||||
Managers are nested `InheritedWidget`/`StatefulWidget` components built by `buildManagerStack()` in `lib/application.dart`:
|
||||
|
||||
```text
|
||||
AppEnvManager > StatusManager > ThemeManager
|
||||
AppEnvManager > LocaleManager > StatusManager > ThemeManager
|
||||
> [Desktop: WindowManager > TrayManager > HotKeyManager > ProxyManager]
|
||||
[Mobile: AndroidManager > TileManager]
|
||||
> AppStateManager > CoreManager > ConnectivityManager
|
||||
@@ -401,7 +584,7 @@ updating both this diagram and that test.
|
||||
|
||||
## Core Controller and Actions
|
||||
|
||||
`lib/core/controller.dart` (`CoreController`) is a singleton facade over `CoreHandlerInterface`. Public methods delegate to the platform-specific interface, either Android FFI or desktop socket. It has an `@visibleForTesting` constructor and `resetInstance()` for test injection.
|
||||
`lib/core/controller.dart` (`CoreController`) is a singleton facade over `CoreHandlerInterface`. Public methods delegate to the platform-specific interface, either the Android service channel (`CoreLib`) or the desktop socket. It has an `@visibleForTesting` constructor and `resetInstance()` for test injection.
|
||||
|
||||
`lib/providers/action.dart` is the public library entry point for action
|
||||
providers. The Riverpod notifier implementations are split by responsibility
|
||||
@@ -410,7 +593,10 @@ directives, so consumers continue to import the same public API:
|
||||
|
||||
- `CommonAction`: update check and common UI operations.
|
||||
- `SetupAction`: config setup and TUN management.
|
||||
- `BackupAction`: backup/restore with WebDAV sync.
|
||||
- `BackupAction`: backup/restore orchestration over a per-run temp work directory; the view supplies where an archive
|
||||
goes (local save, WebDAV upload) or comes from. The archive format lives in `lib/common/backup.dart`, which stages a
|
||||
restore without touching the data directory; files, database rows and settings are applied only after the whole
|
||||
archive has been read and validated.
|
||||
- `CoreAction`: core lifecycle, initialization, coalesced restart, and post-restart profile/running-state application.
|
||||
- `SystemAction`: system integration, tray, coordinated resource cleanup, terminal Core close, exit, and brightness.
|
||||
- `StoreAction`: profile storage operations.
|
||||
@@ -430,28 +616,50 @@ invariant, not a policy, and it must stay inside the notifier where no warm-up o
|
||||
only the policy half: the periodic sweep that discards a key stuck past `updatingStaleTimeout`. Do not move the
|
||||
timeout back into `UpdatingKeys`, and do not widen the disconnect reset to every scope.
|
||||
|
||||
## Platform Managers
|
||||
## Safe Mode
|
||||
|
||||
Desktop:
|
||||
`--dart-define=SAFE_MODE=true` builds a FlClash that never changes the host network, so a development build can run
|
||||
next to an installed one. It is a compile-time constant with two faces: `safeModeBuild` in `lib/common/constant.dart`
|
||||
for the process-wide handles that exist before the provider container, and `safeModeProvider` in
|
||||
`lib/providers/app.dart` for everything reached through Riverpod, which is also what tests override. Settings are never
|
||||
rewritten; each owner reads the flag where it would otherwise act:
|
||||
|
||||
- `WindowManager`
|
||||
- `TrayManager`
|
||||
- `HotKeyManager`
|
||||
- `ProxyManager`
|
||||
- Core config: `SetupAction._effectivePatchConfig` is the one place the profile path and `updateConfig` derive what
|
||||
the Core receives from the user's `PatchClashConfig`. In safe mode it reports `tun.enable: false` and
|
||||
`ExternalControllerStatus.close`, so the generated profile and every `updateConfig` carry no TUN and an empty
|
||||
`external-controller` (the Core's `route.ReCreateServer` binds no RESTful API port) while the settings keep the
|
||||
user's values and the installed app keeps `127.0.0.1:9090`. `SetupAction.requestAdmin` skips authorization.
|
||||
- System clock: `_makeRealProfileTask` sets `write-to-system: false` on whatever `ntp` section the generated profile
|
||||
ends up with, so neither the user's override nor the profile's own NTP config can move the host's time.
|
||||
- System proxy: the `proxy` plugin handle in `lib/common/proxy.dart` is `null`, so `ProxyManager` and exit cleanup
|
||||
never write the OS setting, not even the `stopProxy` that would clear what the installed app configured.
|
||||
- System DNS: `systemDnsCoordinator` is `null` and `shouldPatchSystemDnsProvider` is `false`, so neither the apply nor
|
||||
the restore path runs. The restore path matters because a profile or release build shares the preferences of the
|
||||
installed app (only a macOS or Android debug build has its own bundle id) and would restore from its record.
|
||||
- Login item: the `autoLaunch` handle in `lib/common/launch.dart` is `null`, so neither the boot sync nor the setting
|
||||
listener registers the build's executable or removes the installed app's entry, which on Windows is the same
|
||||
`FlClash` value in the user's Run key.
|
||||
- Privileged Core: `CoreService` resolves the launcher with `hasHelper: false`, which is the direct child-process path
|
||||
on every desktop platform; the Helper keeps managing the installed app's Core.
|
||||
- Core listeners: `SetupAction._setCoreRunning` and the `AppStateManager` suspend listener never call `startListener` or
|
||||
`stopListener`, so the Core's `isRunning` stays false and `updateListeners` binds nothing, while `isStartProvider`
|
||||
and `applyProfile` behave as usual. The installed app keeps the mixed, DNS and other loopback ports.
|
||||
- Android: `sharedStateProvider` hands the service `VpnOptions.enable: false` and `systemProxy: false`, so the
|
||||
foreground service runs without a `VpnService`.
|
||||
- Wi-Fi SSID: `ConnectivityManager` never reads the SSID and `Permissions.checkLocationPermissions` neither checks
|
||||
nor requests location authorization, so `currentSSIDProvider` stays `null` and the excluded-network suspend never
|
||||
trips. The macOS `wifi_ssid` plugin builds its `CLLocationManager` and `CWWiFiClient` lazily, so a build that
|
||||
never asks never reaches locationd or wifid.
|
||||
- Global hotkeys: `HotKeyManager` neither subscribes to hotkey events nor registers the configured shortcuts with the
|
||||
OS; the in-window `Shortcuts` (close window, escape) stay.
|
||||
- URL schemes and launcher shortcuts: `Window.init` skips the Windows registry and Linux desktop-entry registration
|
||||
of the `clash` schemes, and Android never calls `initShortcuts`.
|
||||
- Presentation: `AppEnvManager` shows a `SAFE MODE` banner ahead of the channel banner, and `TrayState.safeMode` gives
|
||||
the tray the `status_4` bug icon, a suffixed tooltip and a menu of only show and exit. `trayStateProvider` and
|
||||
`trayTitleStateProvider` report `showTrayTitle: false`, so the macOS menu bar carries no traffic title.
|
||||
|
||||
Mobile:
|
||||
|
||||
- `AndroidManager`
|
||||
- `TileManager`
|
||||
- `VpnManager`
|
||||
|
||||
Shared:
|
||||
|
||||
- `ConnectivityManager`
|
||||
- `CoreManager`
|
||||
- `AppStateManager`
|
||||
- `StatusManager`
|
||||
- `ThemeManager`
|
||||
A new host integration, anything that changes the OS's routes, proxy, DNS, login items, privileges or VPN state, or
|
||||
that asks the OS for a permission, a global shortcut or a registration, must consult the flag at its owner in the same way and get a test that overrides `safeModeProvider`.
|
||||
|
||||
## Build System
|
||||
|
||||
@@ -583,14 +791,91 @@ artifacts under `src/main` are whatever the last hook run left behind, which is
|
||||
detect. Verify a change here with `./gradlew ":core:buildCMakeDebug[arm64-v8a]" :app:compileFlutterBuildDebug
|
||||
--dry-run`: the Flutter task must be listed first even though the CMake task was requested first.
|
||||
|
||||
#### Android Crash Symbols
|
||||
|
||||
Crashlytics NDK symbolicates a native crash only from libraries that still carry their symbols, and AGP strips every
|
||||
`.so` it packages, so the Android build keeps symbols until `:app`'s own strip step and the APK does not grow:
|
||||
|
||||
- The hook drops `-s` and `-w` from `go_ldflags` for the `c-shared` Android targets only; the desktop Cores stay
|
||||
stripped because nothing consumes their symbols.
|
||||
- `:core` compiles with `-g`, does not `--strip-all`, and sets `packaging.jniLibs.keepDebugSymbols` so its own strip
|
||||
task leaves `libclash.so` and `libcore.so` intact on the way into `:app`'s merged native libs, which is where the
|
||||
Crashlytics plugin reads them.
|
||||
- `:app` turns on `nativeSymbolUploadEnabled` and finalizes `assembleRelease` and `bundleRelease` with
|
||||
`uploadCrashlyticsSymbolFileRelease` only when release signing is configured, the same condition that marks a real
|
||||
release build; the checked-in `google-services.json` is a placeholder, so a local upload is noise at best, and CI
|
||||
supplies both files together. The Crashlytics plugin wires the R8 mapping upload itself but leaves the native symbol
|
||||
upload to the caller, so nothing else triggers it. `generateCrashlyticsSymbolFileRelease` runs `dump_syms` over the
|
||||
Go Core for every ABI, which is the other reason it stays off for unsigned builds.
|
||||
|
||||
## Local Plugins
|
||||
|
||||
- `setup`: build-time harness for Go core artifacts and the Rust helper, driven by a Dart build hook; no runtime Dart API.
|
||||
- `proxy`: system proxy configuration.
|
||||
- `rust_api`: runtime Flutter Rust Bridge FFI package built through Native Assets. See below.
|
||||
- `tray`: system tray for Linux, macOS and Windows. Written for FlClash; replaced the `tray_manager` fork.
|
||||
- `window`: desktop window control for Linux, macOS and Windows. Written for FlClash; replaced the
|
||||
`window_manager` fork. See below.
|
||||
- `wifi_ssid`: Wi-Fi SSID detection.
|
||||
- `flutter_distributor`: app packaging/distribution.
|
||||
- `code_forge`: the text editor behind `EditorPage`, derived from upstream `code_forge`. See below.
|
||||
|
||||
## code_forge Plugin
|
||||
|
||||
`CodeForgeController` is the document: text lives in the Rust `RopeBridge`, and the controller's methods are split by
|
||||
concern into `lib/code_forge/controller/` extensions. `CodeForge` builds `_CodeFieldRenderer`, whose painting, layout,
|
||||
folding, caret and gutter code sit in `lib/code_forge/code_area/` part files.
|
||||
|
||||
- Rust counts offsets in Unicode scalars and Flutter in UTF-16 code units. Convert at the boundary with
|
||||
`text_offsets.dart`; a mixed-up offset only shows up with astral characters such as emoji, so tests use them.
|
||||
- The renderer follows edits through the controller's `lineEdit` (first line, lines removed, lines inserted) and never
|
||||
reads the whole text per edit; a whole-text `text =` assignment resets the renderer instead.
|
||||
- FlClash supplies the suggestion popup, context menu and scrollbar; the package has no built-in versions of them.
|
||||
- Tests live in `test/plugins/code_forge/` at the repository root and need the `rust_api` library.
|
||||
|
||||
## window Plugin
|
||||
|
||||
`plugins/window` exposes `desktopWindow` (`DesktopWindow`) on the `window` method channel; `plugins/window/README.md`
|
||||
is the channel contract (methods, arguments, events, effects, runner hooks) and every platform implements exactly that
|
||||
table. `lib/common/window.dart` and `lib/manager/window_manager.dart` are the only application callers.
|
||||
|
||||
- Each platform keeps one style record (title bar style and button visibility everywhere, rounded corners on
|
||||
Windows, effect, tint and brightness on Windows and macOS) and reapplies it whole from a single `apply` routine, so DWM margins
|
||||
on Windows and `isOpaque`/effect views on macOS have one writer. Add a visual setting to that record, never a second
|
||||
code path.
|
||||
- Method dispatch is a table on every platform, and every handler validates its arguments; a bad call answers with a
|
||||
`bad_args` `PlatformException`, a capability the platform lacks with `unsupported`. Coordinates cross the channel in
|
||||
logical pixels; Windows converts with `GetDpiForWindow`.
|
||||
- Position and size changes surface as one `geometry-changed` event that each platform coalesces natively (150 ms
|
||||
after the last `WM_WINDOWPOSCHANGED`, did-move/did-resize, or configure-event); `WindowManager` only debounces the
|
||||
capture on top of it.
|
||||
- Windows: with `titleBarStyle: hidden`, `WM_NCCALCSIZE` lets `DefWindowProc` compute the client rect and then puts
|
||||
the top edge back (plus the frame height when maximized, so the taskbar stays uncovered; plus 1 px otherwise, which
|
||||
Windows 11 needs to draw the top border and Windows 10 needs to avoid a white line). Fullscreen is emulated by the
|
||||
plugin, which raises `enter-full-screen` and
|
||||
`leave-full-screen` itself and mutes `WM_SIZE` while it is on. The find-running-window IPC
|
||||
(`WindowPluginFindRunningWindow`/`WindowPluginActivateWindow`) registers a window message from the executable path
|
||||
and whitelists it through UIPI so a non-elevated launcher reaches an elevated instance.
|
||||
- Linux: GTK drops the placement of an unmapped window, so `hide` saves the geometry and the `map-event` handler
|
||||
applies it, then corrects the placement for 150 ms while the window manager settles (it may place the window
|
||||
after the map handler ran). `isPositionSupported` is X11-only; Wayland owns placement and re-shown windows land where the
|
||||
compositor puts them. Linux supports no effect: GTK 3 has no desktop-neutral blur protocol, so `isEffectSupported`
|
||||
is true only for `none` and the runner keeps the default visual.
|
||||
- macOS: `WindowPlugin.instance` is reached from `AppDelegate` for `applicationShouldTerminate` (emits
|
||||
`should-terminate`) and `applicationShouldHandleReopen` (emits `activate`); `MainFlutterWindow` calls
|
||||
`hiddenWindowAtLaunch()` so the first `order` keeps the window hidden until Dart shows it.
|
||||
- Window effects (`transparent`, `blur`, `acrylic`, `mica`) are capability-gated by `isEffectSupported`; the Flutter
|
||||
content must paint a translucent background for them to show. `ThemeProps.sidebarBlur` (on by default) drives
|
||||
them: `Window.setBlur` picks `blur` on macOS and `acrylic` (or `blur`) on Windows and caches only a successful
|
||||
probe, so a platform that answered unsupported is asked again the next time blur is turned on. `WindowManager` reapplies it
|
||||
whenever the setting or the app brightness changes and records the outcome in `windowBlurProvider`, and only the
|
||||
sidebar and the caption strip paint translucently while it is on. On macOS the sidebar paints fully transparent
|
||||
and the effect view follows the window's active state, which is the flutter_acrylic `TransparentMacOSSidebar`
|
||||
look; Windows keeps a 50% tint because the accent effects blur too little for the rail to stay readable.
|
||||
- Sidebar blur is soft-disabled: `feature.sidebarBlur` (`lib/common/feature.dart`) is off unless the build passes
|
||||
`--dart-define=FEATURE_SIDEBAR_BLUR=true`. While it is off the theme page hides the toggle and
|
||||
`windowBlurRequestProvider` asks for no effect whatever `ThemeProps.sidebarBlur` holds. `WindowManager` still sends
|
||||
that request at startup: the macOS plugin installs its effect view in `ensureInitialized` and leaves it active until
|
||||
the first `setEffect`, so the `none` request is what makes the window opaque and parks the effect view.
|
||||
|
||||
## rust_api Crate Layout
|
||||
|
||||
@@ -601,6 +886,12 @@ Cargo and registers `librust_api` as a code asset that Flutter bundles and signs
|
||||
the hook refuses `stable` and a target missing from that list. `rustup show` installs the pinned toolchain and those
|
||||
targets on first use.
|
||||
|
||||
`flutter_rust_bridge_codegen generate` (see `.agents/commands.md`) writes `lib/src/rust/frb_generated.dart`,
|
||||
`frb_generated.io.dart`, `frb_generated.web.dart`, one `lib/src/rust/api/<module>.dart` per Rust API module, and
|
||||
`rust/src/frb_generated.rs`; none of them is edited by hand. A new Rust function is a public function in `rust/src/api/`,
|
||||
plus `pub mod <name>;` in `api/mod.rs` and an export from `lib/rust_api.dart` when it starts a new module. `RustLib` is
|
||||
the singleton entry point: `RustLib.init()` runs before any call and `RustLib.dispose()` on teardown.
|
||||
|
||||
`plugins/rust_api/rust/src/` separates the bridge boundary from the code behind it:
|
||||
|
||||
- `api/` is the only input flutter_rust_bridge parses (`rust_input: crate::api`). Every function there is a thin
|
||||
@@ -609,6 +900,10 @@ targets on first use.
|
||||
bounded send queue), `platform` (socket cleanup, Windows peer credentials and the non-blocking pipe reader), and
|
||||
`server` (lifecycle, accept loop, and the `RUNNING`/`STATE` globals).
|
||||
- `script/` runs profile override scripts on QuickJS through `rquickjs`.
|
||||
- `editor/` backs `plugins/code_forge`: a `ropey` buffer with its selection, fold ranges and bracket matching,
|
||||
indent guides for the viewport, and completion words. `api/editor.rs` exposes the buffer as the opaque
|
||||
`RopeBridge`. Only the editor imports this module, through `lib/editor.dart`, so `lib/rust_api.dart` keeps its
|
||||
generic names such as `SelectionState` out of app code.
|
||||
- `hotkey/` registers desktop global shortcuts through `global-hotkey`: `keys` maps Flutter USB HID usages to key
|
||||
codes, `owner` runs every registration on the thread the platform binds it to (a dedicated message-loop thread on
|
||||
Windows, the main dispatch queue on macOS, in place on Linux), and `service` owns the registry and forwards presses
|
||||
@@ -619,7 +914,8 @@ What a platform does not use, it does not compile. `interprocess` and `global-ho
|
||||
Android loads the Core in-process and has no global shortcuts. Adding a capability follows the same shape: implement it in its own module, gate the
|
||||
dependency by target, and keep the `api/` entry point unconditional so one set of bindings still serves every platform.
|
||||
|
||||
`RustLib.init()` runs on every platform now, not only desktop — the script engine is shared.
|
||||
`RustLib.init()` runs on every platform in `main`, because the script engine and the editor are shared; the editor
|
||||
shows its unavailable state when `RustLib` is not initialized rather than loading the library itself.
|
||||
|
||||
## Profile Script Engine
|
||||
|
||||
|
||||
+47
-11
@@ -49,13 +49,17 @@ hooks:
|
||||
build_assets: true # false does the same for the Rust library
|
||||
```
|
||||
|
||||
CI flips both to `false` with `yq` before `dart run tool/changelog.dart` and
|
||||
`flutter test`, and restores the file afterwards; no test loads either 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.
|
||||
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 hooks again. The editor tests still need the Rust library: keep `rust_api`
|
||||
on, or point `FRB_DART_LOAD_EXTERNAL_LIBRARY_NATIVE_LIB_DIR` at a directory that
|
||||
holds a `rust_api` library built from the same sources.
|
||||
|
||||
## Flutter Development
|
||||
|
||||
@@ -69,6 +73,23 @@ flutter test
|
||||
|
||||
Use `flutter test`, not `dart test`, because models pull in Flutter types.
|
||||
|
||||
A development build takes over the host exactly like a release: it creates the TUN device, sets the system proxy, on
|
||||
macOS rewrites the system DNS, registers itself as the login item, and on Windows and Linux starts its Core through the
|
||||
shared privileged Helper, which stops the Core an installed FlClash is running. Only the data directory is kept apart,
|
||||
and only for some builds: a macOS debug build is `com.follow.clash.debug` and an Android debug build `com.follow.clash.dev`,
|
||||
while profile and release builds, and every build on Windows and Linux, share the directory and preferences of the
|
||||
installed app. Safe mode keeps all of the host changes off and never opens the Core's listeners or external
|
||||
controller, so it does not compete with the installed app for the mixed, DNS, RESTful API and other loopback ports:
|
||||
|
||||
```bash
|
||||
flutter run --dart-define-from-file=env.json --dart-define=SAFE_MODE=true
|
||||
```
|
||||
|
||||
`.run/main_safe_mode.run.xml` is the same launch for IntelliJ, and `Flutter App (Safe Mode)` in `.zed/debug.json` (debugger) or `flutter run (Safe Mode)` in `.zed/tasks.json`
|
||||
(plain run) for Zed. The build is bannered `SAFE MODE`, the tray shows the
|
||||
`status_4` bug icon with a menu of only show and exit and no traffic title, and the settings keep their values, so
|
||||
nothing has to be flipped back before a normal run. See the safe mode section of `architecture.md` for the owners that consult the flag.
|
||||
|
||||
## Code Generation
|
||||
|
||||
Run code generation after modifying models, providers, or database schema:
|
||||
@@ -84,6 +105,14 @@ Code generation covers:
|
||||
- Models through `freezed` and `json_serializable`.
|
||||
- Database tables through `drift_dev`.
|
||||
|
||||
The Dart-Rust bindings in `plugins/rust_api` are generated by `flutter_rust_bridge_codegen` from
|
||||
`plugins/rust_api/flutter_rust_bridge.yaml`; rerun it from that directory after changing anything under
|
||||
`rust/src/api/`:
|
||||
|
||||
```bash
|
||||
cd plugins/rust_api && flutter_rust_bridge_codegen generate
|
||||
```
|
||||
|
||||
Generated output paths, configured in `build.yaml`:
|
||||
|
||||
- `lib/models/generated/*.g.dart`, `*.freezed.dart`.
|
||||
@@ -123,6 +152,9 @@ flutter test plugins/proxy/test/proxy_test.dart
|
||||
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
|
||||
run of `plugins/setup` would execute the build hook itself.
|
||||
|
||||
Native plugin code under `plugins/*/{linux,macos,windows}` is compiled by the CI `desktop` job, which runs
|
||||
`flutter build <platform> --debug` on every push; `tool/check_plugins.sh` only analyzes and tests the Dart side.
|
||||
|
||||
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`.
|
||||
|
||||
For the current Core/service architecture, useful focused checks are:
|
||||
@@ -167,12 +199,14 @@ cargo test --manifest-path services/helper/Cargo.toml --features windows-service
|
||||
```
|
||||
|
||||
The last command requires Windows for meaningful service coverage. Native Android lifecycle edits should at minimum
|
||||
compile the modules they touch; use JDK 17 in this checkout:
|
||||
compile the modules they touch, with the JDK 17 that CI installs. On macOS the Homebrew `openjdk@17` keg is not
|
||||
registered with `java_home`, so export it by prefix:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew :service:compileDebugKotlin
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew :app:compileDebugKotlin
|
||||
export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home" # elsewhere: any JDK 17
|
||||
./gradlew :service:compileDebugKotlin
|
||||
./gradlew :app:compileDebugKotlin
|
||||
```
|
||||
|
||||
Always-on VPN entry, system VPN revoke, actual permission UI, and rapid device start/stop still require Android device or
|
||||
@@ -240,8 +274,9 @@ while `v<pubspec version>` is still tagged it refuses to collect anything and th
|
||||
|
||||
## Verify
|
||||
|
||||
Every branch push runs the `dart` job, which performs these root-package checks
|
||||
in order:
|
||||
In the public repository every branch push runs the `dart` job; in the private
|
||||
`origin` every check job runs only when started by hand (`workflow_dispatch`).
|
||||
The `dart` job performs these root-package checks in order:
|
||||
|
||||
```bash
|
||||
bash tool/check_commit_msg_test.sh
|
||||
@@ -254,7 +289,7 @@ flutter test --reporter expanded --coverage
|
||||
dart run tool/check_coverage.dart coverage/lcov.info 75
|
||||
```
|
||||
|
||||
Run `flutter analyze` locally before committing when practical.
|
||||
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
|
||||
@@ -263,8 +298,9 @@ directories: `plugins` (local Flutter packages and the setup build tool), `go`
|
||||
(the Core wrapper, plus an NDK-backed vet of the Android files), `android`
|
||||
(JVM unit tests for `:common`, `:service` and `:app`, with the Flutter compile
|
||||
tasks excluded so no native build hook runs), `rust` (both crates), and a
|
||||
Windows runner for the helper's `windows-service` feature. Release builds start
|
||||
once all of them pass.
|
||||
Windows runner for the helper's `windows-service` feature. A `desktop` job also
|
||||
compiles a debug build per desktop platform. Release builds run only in the
|
||||
public repository and start once every job except `desktop` passes.
|
||||
|
||||
`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script.
|
||||
It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
|
||||
|
||||
+40
-80
@@ -4,21 +4,24 @@ FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with
|
||||
|
||||
## Version Notes
|
||||
|
||||
- Release CI pins Flutter 3.47.1. Local SDK may diverge, so trust the CI
|
||||
version as the source of truth for release builds.
|
||||
- Dart SDK constraint: `>=3.8.0 <4.0.0`. The lower bound is load-bearing and
|
||||
must not be raised to the Dart version the SDK actually ships; see
|
||||
Dependency Ceilings.
|
||||
- Flutter stays on the 3.47.x line. Release CI pins 3.47.4 and is the source
|
||||
of truth for release builds; `.fvmrc` and local SDKs may sit on another 3.47
|
||||
patch. The editor in `plugins/code_forge` came from `code_forge` 10.14.0, which
|
||||
needs Dart 3.13.2, so the CI pin cannot go below Flutter 3.47.2.
|
||||
- Before moving the pin, walk `flutter-upgrade.md`: it lists the workarounds tied to
|
||||
the current SDK, such as Impeller disabled on the Intel macOS release build.
|
||||
- The Dart SDK lower bound in `pubspec.yaml` sets the package language
|
||||
version. `flutter analyze` excludes `lib/**/generated/**`, so a raise is
|
||||
proven by `flutter test`, which parses the generated code, not by analyze.
|
||||
|
||||
## Forked Dependencies
|
||||
|
||||
Three `pubspec.yaml` dependencies are pinned to a fork — `window_manager` by tag,
|
||||
the other two by commit SHA. All three
|
||||
forks live under `chen08209`, the same account that owns this repository, so they
|
||||
are maintained in-house rather than tracked from a third party: advancing a pin
|
||||
is a local decision, and there is no external maintainer to wait on for the patch
|
||||
itself. What each fork still waits on is the *upstream* fix that would let the
|
||||
pin be dropped entirely, recorded below.
|
||||
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
|
||||
maintained in-house rather than tracked from a third party: advancing a pin is a
|
||||
local decision, and there is no external maintainer to wait on for the patch
|
||||
itself. What each fork still waits on is the *upstream* fix that would let the pin
|
||||
be dropped entirely, recorded below.
|
||||
|
||||
Each entry records what the fork changes and what has to be true before it can go
|
||||
back to the published package, so a future upgrade does not have to rediscover it.
|
||||
@@ -29,25 +32,11 @@ of the same number:
|
||||
diff -ru ~/.pub-cache/hosted/pub.dev/<name>-<version> ~/.pub-cache/git/<name>-<sha>
|
||||
```
|
||||
|
||||
`window_manager` — `chen08209/window_manager`, path `packages/window_manager`,
|
||||
version 0.5.1, pinned to the tag `v0.5.1-flclash.1` because the fork carries
|
||||
commits of its own rather than a single patch on top of a release.
|
||||
|
||||
- `windows/window_manager_plugin.cpp`: with `titleBarStyle: hidden` a maximized
|
||||
window uses the monitor work area (`GetMonitorInfo().rcWork`) instead of
|
||||
upstream's `adjustNCCALCSIZE` border fudge, so it no longer covers the taskbar.
|
||||
- `linux/window_manager_plugin.cc`: GTK drops the placement of an unmapped
|
||||
window, so `hide` saves the geometry and the `map-event` handler applies it
|
||||
again. Upstream only moves the window while it is hidden, which a window
|
||||
manager is free to ignore — the window then reappears wherever it decides to
|
||||
place it, which on this repository's Linux runner is every appearance after the
|
||||
first, because `my_application.cc` never shows the toplevel itself.
|
||||
- Adds `setWindowCornerPreference` (Windows) and `handleShouldTerminate` /
|
||||
`onWindowShouldTerminate` (macOS). These lived in a local `window_ext` plugin
|
||||
until they moved here; `lib/manager/window_manager.dart` and
|
||||
`macos/Runner/AppDelegate.swift` are the callers.
|
||||
- Drop the fork once upstream carries all three. The added APIs have call sites,
|
||||
so this is not a pin change alone.
|
||||
The `window_manager` fork (`chen08209/window_manager`, tag `v0.5.1-flclash.3`)
|
||||
was replaced by the in-repo `plugins/window` package; the behaviors it carried
|
||||
(work-area maximize with a hidden title bar on Windows, geometry restore on
|
||||
re-map on Linux, corner preference, terminate and activate events) live there
|
||||
now, see the window plugin section of `architecture.md`.
|
||||
|
||||
`launch_at_startup` — `chen08209/launch_at_startup`, version 0.5.1.
|
||||
|
||||
@@ -66,60 +55,31 @@ commits of its own rather than a single patch on top of a release.
|
||||
- Drop the fork once upstream quotes map keys by the same
|
||||
`isValidUnquotedString` rule it already applies to values.
|
||||
|
||||
The `code_forge` fork (`chen08209/code_forge`) is frozen: its Dart code now lives
|
||||
in the in-repo `plugins/code_forge` package and its Rust code in the `editor/`
|
||||
module of `plugins/rust_api`, both trimmed to what FlClash uses.
|
||||
`plugins/code_forge/README.md` records what was kept and what was removed;
|
||||
upstream's MIT notice stays in `plugins/code_forge/LICENSE`.
|
||||
|
||||
## Dependency Ceilings
|
||||
|
||||
Several dependencies cannot be advanced from this repository, and re-running
|
||||
`flutter pub outdated` will keep listing them. The blocker is upstream in every
|
||||
case, so treat the list as resolved-until-the-ceiling-moves rather than as debt:
|
||||
`flutter pub outdated` lists only upstream-held entries, so treat them as
|
||||
resolved-until-the-ceiling-moves rather than as debt:
|
||||
|
||||
`freezed` is pinned exactly to `3.2.6-dev.1`, which is a pre-release *ahead* of
|
||||
the newest stable `3.2.5`. It is not a stale pin and must not be "fixed" by
|
||||
moving to `3.2.5`: stable `3.2.5` requires `analyzer >=9.0.0 <11.0.0`, while the
|
||||
pinned Flutter SDK resolves `analyzer` 12. `3.2.6-dev.1` is the only published
|
||||
freezed release that accepts `analyzer` 12. Move to a stable release only once
|
||||
one exists that accepts the analyzer the SDK actually resolves.
|
||||
- `intl` is intentionally unbounded (`any`) and `material_color_utilities` is
|
||||
resolved by the Flutter SDK; neither is a bound this repository sets.
|
||||
|
||||
The `>=3.8.0` Dart lower bound is a language-version floor, not a stale minimum.
|
||||
Dart 3.13 makes `final` on a parameter an error, and `freezed` still emits it in
|
||||
the constructors it generates for every collection field it backs with a private
|
||||
field (`const _LogsState({final List<Log> logs = const [], ...})`); 4.0.0-dev.3
|
||||
emits it too. A pubspec's lower bound sets the package language version, so
|
||||
`>=3.8.0` keeps that generated code legal while the SDK itself runs 3.13. Raising
|
||||
the bound makes every `*.freezed.dart` fail to parse, which `flutter analyze`
|
||||
does not catch because `lib/**/generated/**` is excluded. Raise it only once
|
||||
freezed stops emitting the modifier.
|
||||
`freezed`, the `riverpod` set, `build_runner`, `drift_dev`, `intl_utils` and
|
||||
`test` all resolve against the analyzer the pinned SDK ships; none of them is
|
||||
held back. When an upgrade fails version solving, record the blocker here with
|
||||
what has to change upstream before the pin can move, so the next
|
||||
`flutter pub outdated` run does not rediscover it.
|
||||
|
||||
One `analyzer` ceiling holds most of the remaining `flutter pub outdated` list.
|
||||
The newest `build_runner`, `drift_dev`, `intl_utils`, `test`, and
|
||||
`riverpod_generator` all require `analyzer` 13; `test` 1.31.2 additionally
|
||||
requires `test_api` 0.7.13, while `flutter_test` from the pinned SDK depends on
|
||||
`test_api` 0.7.12. Raising any of those bounds therefore fails version solving.
|
||||
|
||||
The `riverpod` chain is the visible symptom. It is held at
|
||||
`riverpod`/`flutter_riverpod` 3.3.2, `riverpod_annotation` 4.0.3, and
|
||||
`riverpod_generator` 4.0.4 as one rigid unit, because `riverpod_annotation`
|
||||
4.0.3 depends on `riverpod` exactly 3.3.2: the runtime cannot move without the
|
||||
generator, and the generator cannot move without `analyzer` 13. Retry the whole
|
||||
set after a Flutter SDK bump raises `test_api`, not before.
|
||||
|
||||
`intl` is intentionally unbounded (`any`) and `material_color_utilities` is
|
||||
resolved by the SDK; neither is a bound this repository sets.
|
||||
|
||||
`isolate_contactor` is discontinued and `isolate_manager` is several majors
|
||||
behind. Both arrive through `re_editor`, which pins `isolate_manager: ^4.1.5+1`
|
||||
and is already at its own latest release. Nothing in this repository can advance
|
||||
them.
|
||||
|
||||
`CorePalette` is deprecated in `material_color_utilities` in favour of
|
||||
`DynamicScheme`/`CorePalettes`, but `dynamic_color` 1.9.0 — its newest release —
|
||||
still exposes only `DynamicColorPlugin.getCorePalette()`, which returns the
|
||||
deprecated type. There is no migration available from this repository short of
|
||||
calling the `io.material.plugins/dynamic_color` method channel directly and
|
||||
decoding the palette int list by hand, which is not worth owning. The deprecated
|
||||
type is therefore confined to `GlobalState._initDynamicColor`, which converts it
|
||||
to the two plain seed colours the app actually consumes; everything downstream
|
||||
sees `DynamicColorSeeds`. Revisit when `dynamic_color` ships a non-deprecated
|
||||
accessor — only that one function has to change.
|
||||
`_initDynamicColor` in `lib/bootstrap.dart` reads the `dynamic_color` method
|
||||
channel directly and decodes the palette list into a `TonalPalette`, instead of
|
||||
going through `DynamicColorPlugin.getCorePalette()` and its deprecated
|
||||
`CorePalette` return type. Everything downstream sees `DynamicColorSeeds`, so a
|
||||
non-deprecated plugin accessor changes only that one function.
|
||||
|
||||
## Build Dependencies
|
||||
|
||||
|
||||
+68
-28
@@ -29,7 +29,9 @@ raw string.
|
||||
|
||||
### Corner Radius
|
||||
|
||||
Rounded corners are superellipses everywhere, not circular arcs. Use the superellipse API at each layer:
|
||||
Rounded corners are superellipses (continuous corners) everywhere, never circular arcs; when a component offers
|
||||
both, pick the superellipse. `test/lint/superellipse_corners_test.dart` fails on the circular APIs below in `lib/`. Use
|
||||
the superellipse API at each layer:
|
||||
|
||||
- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`.
|
||||
- Clips: `ClipRSuperellipse` instead of `ClipRRect`.
|
||||
@@ -37,25 +39,31 @@ Rounded corners are superellipses everywhere, not circular arcs. Use the superel
|
||||
`BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with
|
||||
`clipBehavior` still clips to the shape path.
|
||||
- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`.
|
||||
- Ink: `InkWell.customBorder` with an `AppShape` instead of `InkWell.borderRadius`.
|
||||
|
||||
Passing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only
|
||||
carries the corner magnitude; the rendered geometry stays a superellipse.
|
||||
|
||||
APIs that accept only `BorderRadius` keep circular corners, with the superellipse supplied by an
|
||||
enclosing clip or shape where one is needed: `InkWell.borderRadius`, `OutlineInputBorder`,
|
||||
`ScrollbarThemeData.radius`, and `smooth_sheets`' `MaterialSheetDecoration`. Fully round pills
|
||||
(`BorderRadius.circular(999)` or half the shortest side) may stay circular — both geometries coincide
|
||||
there.
|
||||
Pills are no exception. `StadiumBorder` joins its round caps to the straight sides with a curvature jump, while
|
||||
`AppShape.full`, a superellipse at full radius, blends them, and on a square it renders the exact circle, so it serves
|
||||
circles too. Material 3 defaults every button, icon button, segmented button, and search bar to `StadiumBorder`;
|
||||
`ThemeData.withAppShapes` replaces that for the button themes, and a local `FilledButtonTheme` or `IconButtonTheme`
|
||||
replaces the app's, so merge the ambient `XButtonTheme.of(context).style` into it, as `CommonMinFilledButtonTheme`
|
||||
does. `CircleBorder` and `BoxShape.circle` are fine for something that is always square.
|
||||
|
||||
APIs that accept only a `Radius`, such as `ScrollbarThemeData.radius`, keep circular corners; supply the superellipse
|
||||
with an enclosing clip or shape where it shows.
|
||||
|
||||
CI gates formatting: `dart format --output=none --set-exit-if-changed lib test
|
||||
tool plugins setup.dart` runs before `flutter analyze`.
|
||||
|
||||
Generated directories are excluded from analysis:
|
||||
These directories are excluded from analysis:
|
||||
|
||||
- `build/**`
|
||||
- `lib/l10n/intl/**`
|
||||
- `lib/**/generated/**`
|
||||
- `plugins/**`
|
||||
- the platform runner directories: `android/`, `ios/`, `web/`, `windows/`, `macos/`, `linux/`
|
||||
|
||||
## Comments
|
||||
|
||||
@@ -136,9 +144,10 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
|
||||
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
|
||||
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, JNI, and
|
||||
desktop IPC. Do not double-encode `arguments`, `result`, or event batches.
|
||||
- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update), delay, and
|
||||
bulk (log, request). Delay and bulk evict their own oldest entry under backpressure; state uses `enqueueState`, which
|
||||
never evicts, because a dropped `geoUpdate{updating:false}` leaves `isUpdatingProvider` stuck at true in the UI until
|
||||
- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,
|
||||
route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest
|
||||
entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push
|
||||
out the request events the connection list is built from; state uses `enqueueState`, which never evicts, because a dropped `geoUpdate{updating:false}` leaves `isUpdatingProvider` stuck at true in the UI until
|
||||
`UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full
|
||||
queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports,
|
||||
and reporting it from the message layer feeds the same batcher.
|
||||
@@ -195,12 +204,11 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
|
||||
mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is
|
||||
what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under
|
||||
`configMu`, fixing the order as `configMu` → `selectMu`.
|
||||
- The delay-test semaphore is acquired with a slice of the caller's budget (`budget/delayTestQueueShare`), not
|
||||
unconditionally and not with the whole deadline. Queueing and probing come out of one budget, so a test handed all of
|
||||
it can spend it waiting and reach `URLTest` with nothing left, reporting a proxy it never contacted as unreachable.
|
||||
The probe keeps the caller's original deadline, so whatever the queue did not use is still its own.
|
||||
- Queueing for the delay-test semaphore and probing the node each get the full timeout in `handleTestDelay`. With one
|
||||
shared deadline, a node that waited behind a saturated semaphore reached `URLTest` with almost nothing left, so a bulk
|
||||
test of a large subscription reported whatever sat at the back of the queue as unreachable.
|
||||
- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy:
|
||||
`handleTestDelay` returns inside its own budget on every path. `asyncTestDelay` therefore returns null instead of a
|
||||
`handleTestDelay` returns within its two timeouts on every path. `asyncTestDelay` therefore returns null instead of a
|
||||
`-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a
|
||||
timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's.
|
||||
- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0
|
||||
@@ -254,8 +262,14 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
|
||||
actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.
|
||||
- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the
|
||||
broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.
|
||||
- Anything that probes through the outbound route is a `RoutedProbe` judged against `routeTrackerProvider` (Route
|
||||
Consistency in `.agents/architecture.md`); the Core stamps every answer and publishes its picks, so do not add a
|
||||
change signal, a trigger counter, or a `coreStatusProvider` watch of your own.
|
||||
- Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or
|
||||
overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately.
|
||||
- Safe mode (`SAFE_MODE` define, see the safe mode section of `.agents/architecture.md`) is consulted by each host
|
||||
integration at its owner, never by rewriting the user's settings. `safeModeBuild` is for handles created before the
|
||||
container exists; everything else reads `safeModeProvider` so a test can override it.
|
||||
- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to "`show` was never called".
|
||||
`AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins.
|
||||
Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)`
|
||||
@@ -299,8 +313,6 @@ connection `captureFrames` has installed by then. Either keep the event out of t
|
||||
connection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches
|
||||
it in an unrelated test.
|
||||
|
||||
Use `CoreController.test(mock)` to inject a mocked `CoreHandlerInterface`. Call `CoreController.resetInstance()` in `tearDown` to clean up the singleton between tests.
|
||||
|
||||
Register fallback values for freezed params used with `any()` matchers.
|
||||
|
||||
`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a
|
||||
@@ -313,7 +325,8 @@ land.
|
||||
|
||||
Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and
|
||||
touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read
|
||||
resolve to the same fake, so it cannot fail on a call site that still reaches for the global.
|
||||
resolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim
|
||||
the singleton calls `CoreController.resetInstance()` in `tearDown`.
|
||||
|
||||
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
|
||||
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
|
||||
@@ -326,10 +339,10 @@ key), so a test that skips the seam registers the test binary on the machine tha
|
||||
under `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test
|
||||
at all; `test/common/launch_test.dart` pins the early return instead.
|
||||
|
||||
`pumpAndSettle` never returns on a page holding `EditorPage`: the code editor blinks its caret forever, so frames keep
|
||||
being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in `common/task.dart` hand work to a real
|
||||
isolate through `compute`, which only runs outside the fake-async zone, so a test awaiting one needs
|
||||
`tester.runAsync(...)` between the pumps — see `test/views/profile_preview_test.dart`.
|
||||
`pumpAndSettle` never returns on a page holding `EditorView` (what `EditorPage` embeds): the code editor blinks its
|
||||
caret forever, so frames keep being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in
|
||||
`common/task.dart` hand work to a real isolate through `compute`, which only runs outside the fake-async zone, so a test
|
||||
awaiting one needs `tester.runAsync(...)` between the pumps.
|
||||
|
||||
The `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it
|
||||
replaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the
|
||||
@@ -359,7 +372,7 @@ The same seam carries the two other places a platform decision changed what was
|
||||
takes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because
|
||||
`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS
|
||||
shape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every
|
||||
non-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window_manager` channel mock that
|
||||
non-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window` channel mock that
|
||||
answers `isMaximized`/`isAlwaysOnTop` with a bool.
|
||||
|
||||
Auto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,
|
||||
@@ -405,7 +418,10 @@ throws "`…State.dispose failed to call super.dispose.`" in debug and profile b
|
||||
dispose()` and hand async teardown to `unawaited(...)`; `Future<void> dispose() async` compiles and is the shape that
|
||||
invites the bug.
|
||||
|
||||
Use `ProviderContainer` directly for simple Riverpod provider tests. The generated Riverpod `update()` method takes a callback:
|
||||
Use `ProviderContainer` directly for simple Riverpod provider tests, released with `addTearDown(container.dispose)`.
|
||||
Assert against the provider's generated public API, not its implementation, re-read source defaults before asserting
|
||||
them, and wait on provider futures or state changes rather than fixed sleeps. The generated Riverpod `update()` method
|
||||
takes a callback:
|
||||
|
||||
```dart
|
||||
notifier.update((state) => newValue);
|
||||
@@ -416,6 +432,17 @@ When testing freezed models with nested objects, always round-trip through `json
|
||||
For async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success,
|
||||
failure, disposal, and any timer boundary that changes visible state.
|
||||
|
||||
## Running the App Locally
|
||||
|
||||
- An agent starts FlClash on the host only as a safe mode build (`--dart-define=SAFE_MODE=true`, see
|
||||
`.agents/commands.md`). A normal build takes over the host network, so the user runs it, never the agent.
|
||||
- Never end a FlClash process the agent did not start, and never kill by name (`pkill`, `killall`, a pattern on the
|
||||
executable path): the user's own instance matches too. Record the pid of every process you launch, check with
|
||||
`ps -o pid,command -p <pid>` that its executable is your own build output, and end only that pid.
|
||||
- Only a macOS or Android debug build has its own data directory; every other build shares the installed app's data
|
||||
directory and single-instance lock, so it cannot start while the user's instance runs. `.agents/commands.md` lists
|
||||
which builds are kept apart.
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Subjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs
|
||||
@@ -440,6 +467,18 @@ Write what the change does, not that something changed: `perf(views): stop redoi
|
||||
|
||||
Install the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`.
|
||||
|
||||
### Fixup Commits
|
||||
|
||||
A follow-up to a change that has not shipped in a stable release goes in as `git commit --fixup=<target>`, aimed at the
|
||||
commit that introduced what it amends.
|
||||
|
||||
- Only stable tags (`vX.Y.Z`) close a commit. A target reached only by pre-release tags (`vX.Y.Z-pre.N`) is still open
|
||||
for fixups, so do not steer away from it or flag it.
|
||||
- A commit a stable tag contains takes no fixup; land the follow-up as a regular commit. Check with
|
||||
`git tag --contains <target> | grep -v -- '-pre'`, which must print nothing.
|
||||
- Asking for a fixup means creating the `fixup!` commit only. Do not run `git rebase --autosquash` or otherwise fold it
|
||||
into its target unless the user asks for the squash.
|
||||
|
||||
### Changelog Trailers
|
||||
|
||||
`tool/changelog.dart` builds the user facing changelog from the commit history, so the trailers below are the copy that
|
||||
@@ -452,16 +491,17 @@ Changelog: Per-profile override scripts
|
||||
```
|
||||
|
||||
- `Changelog:` is the English entry. `Changelog: skip` drops the commit from the changelog entirely.
|
||||
- The changelog is English only. Translation trailers were removed on purpose: they pushed release copy into the commit
|
||||
history, so any `Changelog-<locale>:` or `Breaking-<locale>:` now fails the hook. Translate after the fact if ever
|
||||
needed, not in the commit message.
|
||||
- The changelog is English only. A `Changelog-<locale>:` or `Breaking-<locale>:` trailer fails the hook, because
|
||||
translated release copy does not belong in the commit history. Translate after the fact if ever needed, not in the
|
||||
commit message.
|
||||
- `Changelog-Type:` moves an entry into another group, for example to promote a `refactor` that users will notice. Valid
|
||||
values are `breaking`, `feat`, `fix`, `perf`, `revert`.
|
||||
- `BREAKING CHANGE:` is required whenever the subject carries `!`, and its text becomes the breaking entry. A `!` commit
|
||||
therefore needs two lines of copy: the footer for the breaking entry and `Changelog:` for the normal one.
|
||||
|
||||
`feat`, `fix`, `perf`, `revert` and breaking commits are collected by default; every other type is dropped unless it
|
||||
carries a `Changelog:` trailer. Commits missing a trailer reuse their subject, and the hook says so without blocking.
|
||||
carries a `Changelog:` trailer. Commits missing a trailer reuse their subject; for `feat`, `fix` and `perf` the hook
|
||||
says so without blocking.
|
||||
|
||||
## Generated Code
|
||||
|
||||
|
||||
+9
-3
@@ -1,18 +1,24 @@
|
||||
# Agent Skills
|
||||
|
||||
Repo-scoped Codex skills live under `.agents/skills/*/SKILL.md`. Codex can discover these by skill `name` and `description`, then load the full instructions only when a task matches.
|
||||
Repo skills live under `.agents/skills/*/SKILL.md`, where Codex discovers them directly. Claude Code discovers them
|
||||
through the symlinks in `.claude/skills/`, one per skill, pointing back at the `.agents/skills/` directory. Both tools
|
||||
match a task against the skill `name` and `description`, then load the full instructions only when it matches.
|
||||
|
||||
## Available Repo Skills
|
||||
|
||||
- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.
|
||||
- `provider-tests`: Riverpod provider, notifier, and state-management tests.
|
||||
- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, async feedback, and user-facing interactions.
|
||||
- `core-platform`: Core lifecycle/process ownership, Android services, Go event delivery, desktop IPC, platform managers,
|
||||
VPN/TUN, and Windows Helper flow.
|
||||
- `pre-commit-quality-review`: lightweight triage before commits, with scoped structural review and user decisions on concrete regressions.
|
||||
|
||||
## Authoring Notes
|
||||
|
||||
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`.
|
||||
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`, and add the matching
|
||||
`.claude/skills/<skill-name>` symlink so Claude Code sees it too; remove both when retiring a skill.
|
||||
- Git stores the symlinks as links only where the checkout supports them. A Windows checkout needs
|
||||
`core.symlinks=true` and Developer Mode, otherwise the links become plain files and Claude Code loads no repo
|
||||
skill there; Codex is unaffected because it reads `.agents/skills/` directly.
|
||||
- Keep skill descriptions trigger-focused and start them with `Use when...`.
|
||||
- Keep long reference material in `.agents/*.md`; skills should link to it instead of duplicating it.
|
||||
- Put command permission rules in `.codex/rules/*.rules`, not in this file.
|
||||
|
||||
@@ -1,73 +1,63 @@
|
||||
---
|
||||
name: core-platform
|
||||
description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, VPN/TUN, or Windows Helper flow.
|
||||
description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, build hooks, system proxy, tray, VPN/TUN, or the Windows or Linux Helper.
|
||||
---
|
||||
|
||||
# Core And Platform
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules, build
|
||||
hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
|
||||
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules,
|
||||
build hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
|
||||
|
||||
## Workflow
|
||||
## Read First
|
||||
|
||||
1. Identify the authoritative owner before changing behavior:
|
||||
- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.
|
||||
- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.
|
||||
- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.
|
||||
- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.
|
||||
- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.
|
||||
- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows Helper HTTP contract:
|
||||
`lib/core/desktop/helper_client.dart` and `services/helper/`.
|
||||
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; UI/event observation: `lib/manager/`.
|
||||
2. Trace every entry path into that owner, including UI/provider calls, Quick Settings, notification actions, Always-on VPN,
|
||||
revoke callbacks, application exit, and crash/disconnect recovery. Lifecycle callbacks are not implicit user intent.
|
||||
3. Preserve latest-intent semantics:
|
||||
- Desktop revisions converge to running/restarted/stopped/closed and report applied/coalesced/superseded outcomes.
|
||||
- Android Flutter calls stay optimistic; `ServiceState` identity-checks the latest native `RunRequest`.
|
||||
4. Route feature calls through `CoreController` and `CoreHandlerInterface`. Do not bypass desktop process leases or create a
|
||||
second Android service binding owner.
|
||||
5. Keep JSON envelopes and event shapes identical across Dart, Go, JNI, and desktop IPC. If event traffic changes, preserve
|
||||
the separate priority and bulk queues in `core/message.go`.
|
||||
6. Keep shutdown single-owned and terminal. `SystemExitCoordinator` sequences resource cleanup, window close, Core close,
|
||||
and process exit; widget/manager disposal must not race it.
|
||||
7. Add or update focused tests at the narrowest layer, then run the matching commands from `.agents/commands.md`:
|
||||
- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.
|
||||
- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.
|
||||
- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.
|
||||
- Android Kotlin: compile each touched Gradle module with JDK 17.
|
||||
- Windows Helper: Cargo format/tests; run the `windows-service` feature on Windows.
|
||||
8. Explicitly state host gaps. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, and Windows Service
|
||||
Control Manager behavior need their real platform even when portable tests pass.
|
||||
- `.agents/rules.md`: Core API Safety and Lifecycle Rules are binding. They carry the mihomo-fork patch budget, the
|
||||
JSON envelope and `core/message.go` queue rules, JNI ownership, `BootGuard`, tray, and SSID rules, and every
|
||||
lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,
|
||||
optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).
|
||||
- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,
|
||||
Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,
|
||||
the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.
|
||||
|
||||
## Reference Files
|
||||
## Owners
|
||||
|
||||
Read `.agents/architecture.md` for the current core modes, manager stack, build hooks, local plugins, and Windows helper notes.
|
||||
Every behavior here has one authoritative owner, and a change lands there:
|
||||
|
||||
- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.
|
||||
- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.
|
||||
- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.
|
||||
- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.
|
||||
- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.
|
||||
- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows and Linux Helper HTTP contract:
|
||||
`lib/core/desktop/helper_client.dart` and `services/helper/`.
|
||||
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; application exit:
|
||||
`SystemExitCoordinator`; UI/event observation: `lib/manager/`.
|
||||
|
||||
An owner has more entry paths than the UI: provider calls, Quick Settings, notification actions, Always-on VPN, revoke
|
||||
callbacks, application exit, and crash/disconnect recovery all reach it. Always-on startup is explicit through
|
||||
`VPN_START_REQUESTED` and revoke through `VPN_REVOKED`; service creation or destruction is never read as intent.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused tests live at the narrowest layer, and the matching commands are in `.agents/commands.md`:
|
||||
|
||||
- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.
|
||||
- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.
|
||||
- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.
|
||||
- Android Kotlin: compile each touched Gradle module with JDK 17.
|
||||
- Helper: `cargo fmt --check` and `cargo test`; the `windows-service` feature only builds on Windows.
|
||||
|
||||
State host gaps explicitly. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, polkit and systemd
|
||||
on Linux, and Windows Service Control Manager behavior need their real platform even when portable tests pass.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Keep the Windows Helper protocol and Core SHA256 validation identical across
|
||||
Flutter build modes; the Helper owns executable integrity checks.
|
||||
- Protocol version 6 uses a 32-character lowercase-hex session ID. `/start` must return the submitted session and PID;
|
||||
`/stop` must never terminate a different session; Dart must verify the connected named-pipe peer PID.
|
||||
- `/start` must release the previously managed Core before it verifies, so no `/start` outcome leaves a Helper-managed
|
||||
Core behind for the caller's direct-launch fallback to race.
|
||||
- The Helper owns a managed Core until its exit is confirmed. A `200` from `/stop` means the Core is gone; when
|
||||
termination cannot be confirmed the Helper keeps the child and answers `coreStopFailed`, and `/start` reports the same
|
||||
code instead of spawning a replacement. Keep that code out of the Dart pre-spawn fallback set in
|
||||
`helper_client.dart`, or the direct launch will race a Core the Helper still owns.
|
||||
- TUN is not a required run condition. Degrading to the unelevated direct Core — and silently losing TUN — is the
|
||||
expected outcome whenever the Helper path fails; do not fail the launch instead.
|
||||
- A desktop process lease with unconfirmed exit must remain owned until cleanup succeeds. Do not discard it and start a
|
||||
replacement Core.
|
||||
- `CoreController.close()` is terminal. Do not call it from a reusable manager lifecycle or recover by starting it again.
|
||||
- `ServiceBroadcastReceiver.goAsync()` must finish once even on timeout; its watchdog releases the broadcast only and must
|
||||
not become a service timeout.
|
||||
- Do not interpret service creation/destruction as start/stop intent. Always-on startup is explicit through
|
||||
`VPN_START_REQUESTED`; revoke is explicit through `VPN_REVOKED`.
|
||||
- Keep log/request floods from evicting state-bearing Core events. Each queue may evict only its own oldest item.
|
||||
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
|
||||
a scope-specific cleanup API instead.
|
||||
- `plugins/setup/` is a build harness, not a Dart API plugin.
|
||||
- Build hooks can trigger Go or Rust compilation indirectly through Flutter platform builds.
|
||||
- A `200` from Helper `/stop` means the Core is gone. When termination cannot be confirmed the Helper keeps the child
|
||||
and answers `coreStopFailed`, and `/start` reports the same code instead of spawning a replacement. Keep that code
|
||||
out of the Dart pre-spawn fallback set in `helper_client.dart`, or the direct launch races a Core the Helper still
|
||||
owns.
|
||||
- The Helper owns executable integrity: Core SHA256 validation and the protocol stay identical across Flutter build
|
||||
modes, so a debug build is not exempt.
|
||||
- `plugins/setup/` is a build harness, not a Dart API plugin, and its hooks can trigger Go or Rust compilation
|
||||
indirectly through Flutter platform builds; a missing toolchain fails `flutter test`, not only `flutter build`.
|
||||
|
||||
@@ -13,33 +13,33 @@ Do not use this for README translation sync or manual edits to generated localiz
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm `pubspec.yaml` still uses `flutter_intl`, source ARBs under `arb/`, and generated output under `lib/l10n/`.
|
||||
2. Scan user-facing Dart text before opening many files:
|
||||
1. Scan user-facing Dart text before opening many files. `flutter_intl` reads `arb/` and writes `lib/l10n/`:
|
||||
|
||||
```bash
|
||||
rg -n "[\\p{Han}]" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'
|
||||
```
|
||||
|
||||
3. Inspect the smallest relevant call sites and nearby ARB keys.
|
||||
4. Add or update every source ARB:
|
||||
2. Inspect the smallest relevant call sites and nearby ARB keys.
|
||||
3. Add or update every source ARB:
|
||||
- `arb/intl_en.arb`
|
||||
- `arb/intl_zh_CN.arb`
|
||||
- `arb/intl_ja.arb`
|
||||
- `arb/intl_ru.arb`
|
||||
5. Replace inline strings with existing project accessors:
|
||||
- Widgets with `BuildContext`: `context.appLocalizations.key` from `common.dart`.
|
||||
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `app_localizations.dart`.
|
||||
6. Regenerate:
|
||||
4. Replace inline strings with existing project accessors:
|
||||
- Widgets with `BuildContext`: `context.appLocalizations.key` from `lib/common/context.dart`.
|
||||
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `lib/common/app_localizations.dart`.
|
||||
5. Regenerate:
|
||||
|
||||
```bash
|
||||
dart run intl_utils:generate
|
||||
```
|
||||
|
||||
7. Verify changed Dart files with `flutter analyze` when practical.
|
||||
8. Re-run the Han-text scan for targeted strings.
|
||||
6. Run `flutter analyze --no-fatal-infos` and `flutter test test/l10n test/lint/dynamic_message_key_test.dart`. The
|
||||
lint test covers the labels built from a runtime key, such as `action_${HotAction.name}`, which the analyzer cannot
|
||||
see; the ARB section of `.agents/rules.md` explains them.
|
||||
7. Re-run the Han-text scan for targeted strings.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- If ja/ru still show English, fix ja/ru source ARB values and regenerate. Do not edit generated Dart.
|
||||
- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.
|
||||
- If generator or analyzer hits local cache permission friction, rerun serially before treating it as a code issue.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: pre-commit-quality-review
|
||||
description: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.
|
||||
---
|
||||
|
||||
# Pre-commit Quality Review
|
||||
|
||||
Check that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,
|
||||
so later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill
|
||||
grants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is
|
||||
not consent.
|
||||
|
||||
## Triage
|
||||
|
||||
Most commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a
|
||||
repository-wide scan.
|
||||
|
||||
- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the
|
||||
normal commit flow without extra confirmation. Say the review was skipped, never that it passed.
|
||||
- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions
|
||||
change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws
|
||||
responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.
|
||||
- An explicit structural review request bypasses triage.
|
||||
|
||||
The snapshot under review is the intended commit compared with its baseline:
|
||||
|
||||
| Operation | Baseline |
|
||||
|---|---|
|
||||
| Regular commit | `HEAD` |
|
||||
| Amend | Parent of the commit being replaced |
|
||||
| Squash | Version preceding the agreed range |
|
||||
| Initial commit | Empty tree |
|
||||
|
||||
Ask when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of
|
||||
partially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial
|
||||
staging.
|
||||
|
||||
## Review the Final Structure
|
||||
|
||||
Scope the review to the responsibility boundaries this change touched. Read the complete final implementation of the
|
||||
affected units and compare before and after, not only the last patch. Report issues this change introduced or
|
||||
worsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a
|
||||
concrete concern needs, and disclose missing essential context rather than claiming a complete pass.
|
||||
|
||||
Focus on:
|
||||
|
||||
- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled
|
||||
with implementation detail; responsibilities in layers that should not own them.
|
||||
- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate
|
||||
nothing; similar names or interfaces hiding different responsibilities.
|
||||
- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually
|
||||
dependent helpers; names and decomposition that do not reduce reading effort.
|
||||
- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,
|
||||
scattered ownership of error handling, cleanup, or state updates.
|
||||
- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite
|
||||
invalidated; coupling that makes existing test boundaries hard to use.
|
||||
|
||||
Not findings: length, a missing design pattern, naming taste, or anything without code evidence and a real
|
||||
maintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,
|
||||
or demand abstractions for speculative needs.
|
||||
|
||||
Business logic, UI behavior, performance, security, platform compatibility, and full functional review are out of
|
||||
scope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or
|
||||
widening this review.
|
||||
|
||||
## Findings and the User's Decision
|
||||
|
||||
A finding qualifies only when all three hold:
|
||||
|
||||
- Before-and-after code and the necessary call relationships show this change introduced or worsened it.
|
||||
- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one
|
||||
responsibility. Clarity or elegance alone is not enough.
|
||||
- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.
|
||||
|
||||
With no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the
|
||||
existing commit authorization. Do not ask for confirmation because a review happened, and do not collect optional
|
||||
improvements into a suggestion list.
|
||||
|
||||
With qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,
|
||||
and give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the
|
||||
smallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to
|
||||
optimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the
|
||||
user recognizes their configured workflow, for example: "The structural issues above support consolidating the
|
||||
duplicate state-update paths. Should I make that change before committing?"
|
||||
|
||||
Existing explicit authorization for these findings is enough to act without asking again. The user may accept all,
|
||||
some, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were
|
||||
restaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.
|
||||
|
||||
## Resolve and Finish
|
||||
|
||||
Once authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task
|
||||
scope; explain separately if either must change. Verify only the accepted findings and the direct effects of the
|
||||
adjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold
|
||||
and exceeds the existing authorization.
|
||||
|
||||
Before committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and
|
||||
review only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,
|
||||
`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse
|
||||
results that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot
|
||||
as verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the
|
||||
commit.
|
||||
|
||||
Report the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.
|
||||
Claim the commit is done only after it exists.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: provider-tests
|
||||
description: Use when adding or updating FlClash Riverpod provider tests, notifier tests, or state-management tests in this repository.
|
||||
---
|
||||
|
||||
# Provider Tests
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for tests under `test/providers/` or any change that validates Riverpod providers, generated notifiers, app state defaults, or provider interactions.
|
||||
|
||||
For broader test expansion, pair this with `.agents/rules.md` and `.agents/commands.md`.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the provider under test and its generated public API before writing assertions.
|
||||
2. Use `ProviderContainer` directly when no widget tree is needed.
|
||||
3. Dispose containers in teardown or with `addTearDown(container.dispose)`.
|
||||
4. Prefer generated notifier APIs over implementation details. Generated `update()` takes a callback:
|
||||
|
||||
```dart
|
||||
notifier.update((state) => newValue);
|
||||
```
|
||||
|
||||
5. Mock external dependencies with `mocktail`; register fallback values for freezed params used with `any()`.
|
||||
6. Keep tests focused on behavior: defaults, state transitions, persistence boundaries, and side effects.
|
||||
7. Run the narrowest relevant test first:
|
||||
|
||||
```bash
|
||||
flutter test test/providers/
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Do not use `dart test`; FlClash models and provider tests may depend on Flutter types.
|
||||
- Re-check source defaults before asserting them; provider defaults can drift.
|
||||
- If async provider timing matters, wait on provider futures or state changes instead of fixed sleeps.
|
||||
@@ -1,42 +1,47 @@
|
||||
---
|
||||
name: ui-work
|
||||
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, navigation surfaces, async feedback, or user-facing interactions.
|
||||
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, icons or glyphs, shapes and corner radii, navigation surfaces, async feedback, or user-facing interactions.
|
||||
---
|
||||
|
||||
# UI Work
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows, dialogs, and interaction behavior.
|
||||
Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows,
|
||||
dialogs, and interaction behavior.
|
||||
|
||||
## Workflow
|
||||
## Conventions
|
||||
|
||||
1. Locate existing nearby widgets and reuse their patterns before adding new abstractions.
|
||||
2. Follow current Material You and Surfboard-like visual conventions.
|
||||
3. Use existing providers, notifiers, and helpers where possible.
|
||||
4. Keep `child:` last in widget constructors.
|
||||
5. Prefer `const` constructors and final locals.
|
||||
6. Localize user-facing text through ARB; use `localization` when text changes are non-trivial.
|
||||
7. Add focused widget tests when behavior changes, especially for rendering states, taps, scrolling, and empty/error states.
|
||||
8. For asynchronous controls, define separately:
|
||||
- authoritative provider/domain state;
|
||||
- display-only state such as a minimum progress duration;
|
||||
- tap policy while work or display holds are active;
|
||||
- failure/disposal cleanup, normally in `finally` for animations and timers.
|
||||
9. Run targeted verification:
|
||||
|
||||
```bash
|
||||
flutter analyze
|
||||
flutter test test/widgets/
|
||||
```
|
||||
- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the
|
||||
Material You, Surfboard-like look the rest of the app has. Do not introduce a visual system for one screen or rewrite
|
||||
a layout the request does not need.
|
||||
- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,
|
||||
and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not
|
||||
await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).
|
||||
- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows
|
||||
section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.
|
||||
- Every icon lives in `lib/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it
|
||||
with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled
|
||||
button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the
|
||||
fill animation, and when to fill.
|
||||
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
|
||||
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
|
||||
keep scrims from stacking.
|
||||
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
|
||||
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
|
||||
- User-facing text goes through ARB; use `localization` when text changes are non-trivial.
|
||||
- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a
|
||||
minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in
|
||||
`finally` for animations and timers. `CoreStatusButton` and the proxies delay test action are the reference implementations,
|
||||
described under User-Facing Core And Delay Feedback in `.agents/architecture.md`.
|
||||
|
||||
## Corner Radii
|
||||
|
||||
All corner radii come from `lib/common/shape.dart`. Never write a radius literal in a widget.
|
||||
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**, not by what looks good in isolation. A radius that reads as
|
||||
a soft card at 64 logical pixels tall reads as a pill at 24 and as a square at 400, so a single radius everywhere is
|
||||
the wrong kind of consistency.
|
||||
The scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 logical pixels tall
|
||||
reads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.
|
||||
|
||||
| Token | Value | Shortest side | Use |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -47,63 +52,54 @@ the wrong kind of consistency.
|
||||
| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |
|
||||
| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |
|
||||
| `xxl` | 28 | over 200 | sheets, dialogs, full-screen containers |
|
||||
| `full` | 1000 | - | pills and circles: tracks, indicators, progress, avatars |
|
||||
| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |
|
||||
|
||||
`AppCorner.fit(shortestSide)` applies that table at runtime and is the right call whenever the size comes from a
|
||||
`LayoutBuilder` or scales with text size. It snaps to the largest token that stays at or under one third of the
|
||||
shortest side, which is the rule the table encodes.
|
||||
- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`
|
||||
builders; `AppShape` mirrors it as `RoundedSuperellipseBorder`, `full` included, plus `circle`, `input`, and the
|
||||
`all`/`top`/`vertical`/`of` builders. Use `AppShape.full` for pills, never `StadiumBorder`; the Corner Radius
|
||||
section of `.agents/rules.md` explains why and lists the superellipse API per layer.
|
||||
- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever
|
||||
the size comes from a `LayoutBuilder` or scales with text.
|
||||
- `ThemeData.withAppShapes` in `lib/common/shape.dart` already applies the scale to card, dialog, bottom sheet, snack
|
||||
bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon
|
||||
button themes. Do not restate those shapes at call sites, and
|
||||
leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
|
||||
|
||||
- `AppCorner` holds the scale as `double`, for `radius:` on `CommonCard` and for arithmetic.
|
||||
- `AppRadius` mirrors it as `BorderRadius`, plus `all`, `top`, and `vertical` builders.
|
||||
- `AppShape` mirrors it as `RoundedSuperellipseBorder`, plus `full` (stadium), `circle`, `input`, and the
|
||||
`all`/`top`/`vertical`/`of` builders.
|
||||
- `ThemeData.withAppShapes` in `lib/application.dart` applies the scale to card, dialog, bottom sheet, snack bar,
|
||||
chip, menu, input, FAB, navigation indicator, and progress themes. Do not restate those shapes at call sites; in
|
||||
particular, leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
|
||||
`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its
|
||||
corner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`
|
||||
(`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy
|
||||
group header in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the
|
||||
one-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the
|
||||
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card at `ProxyCardType.min` is only about
|
||||
64 tall, which caps its radius at 21, so `xl` would break on a setting the user can change at any time while `lg`
|
||||
clears all three heights and the smallest dashboard tile.
|
||||
|
||||
The three tiers between `md` and `xxl` each answer to a different size class, and the split is what keeps one radius
|
||||
from being wrong for two of them.
|
||||
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts
|
||||
the floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8
|
||||
up. `AppInputBorder` clips the notch region and paints the full superellipse through it, as Flutter's own
|
||||
`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.
|
||||
|
||||
`xl` is for a surface that spans the full width. A wide, short strip needs a larger corner than its height alone
|
||||
suggests, or it stops reading as a card. Everything full-bleed shares this token and must stay on it:
|
||||
`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`
|
||||
(`generateSectionV3`), and the proxy group header in `lib/views/proxies/list.dart`. The value is deliberately under
|
||||
half the height of a standard row, so nothing gets clamped and every one of them renders at the identical radius no
|
||||
matter which is taller. This is the one place the one-third rule is knowingly overshot; the width is what carries
|
||||
it, and `fit()` is not used here.
|
||||
Nested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute
|
||||
the outer value, as `_cardRadius` in `lib/widgets/popup.dart`, `thumbRadius + _kThumbInset` in `lib/widgets/tab.dart`, and the
|
||||
selection ring in `lib/widgets/palette.dart` do. Adding a token for one of these is how a scale grows without bound.
|
||||
|
||||
`lg` is for the mid-size card that is not full-bleed: dashboard tiles and proxy node cards. It exists because `xl`
|
||||
does not fit all of them. A proxy node card's height follows the user's `ProxyCardType` setting, and at `min` it is
|
||||
only about 64 tall, which caps its radius at 21 — so `xl` would break on a setting the user can change at any time,
|
||||
while `lg` clears every one of the three heights. The same value has room to spare on the smallest dashboard tile,
|
||||
about 177x80, so both surfaces can hold one token instead of branching per size.
|
||||
A component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass
|
||||
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/widgets/sidebar.dart` does with 10, between the
|
||||
chip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.
|
||||
|
||||
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. That one subtracts the
|
||||
floating label's notch from the outline as a two-pixel band along the top edge, which assumes the label sits over a
|
||||
flat run of border. Any radius from 8 up puts the corner curve under the notch instead, the subtraction takes the
|
||||
corner with it, and the top edge is left drawn a pixel low. `AppInputBorder` clips the notch region away and paints
|
||||
the full superellipse through it, so the corner arc is truncated exactly where the notch begins. That is what
|
||||
Flutter's own `OutlineInputBorder` does by shortening the corner arc's sweep, which is why the framework border
|
||||
survives large radii and the package one does not. The border needs no `contentPadding` compensation; leave Material's
|
||||
defaults alone.
|
||||
## Verification
|
||||
|
||||
Nested radii are derived, never tokens. Concentric corners need `outer = inner + inset`, so name the inset and
|
||||
compute the outer value: `_cardRadius` in `lib/widgets/popup.dart`, `_kCornerRadius` in `lib/widgets/tab.dart`, and
|
||||
the selection ring in `lib/widgets/palette.dart` all do this. Adding an intermediate token to spell one of these out
|
||||
is what makes a scale grow without bound.
|
||||
- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,
|
||||
including the exception path of any async action. Run `flutter analyze` and `flutter test test/widgets/` plus the
|
||||
tests of the touched view.
|
||||
- The scans in `test/lint/` fail on the common UI mistakes and run as part of `flutter test`: an `IconButton` whose
|
||||
icon is an icon needs a `tooltip`; a field that constructs its own controller, notifier, or focus node is released in
|
||||
the same file; a public declaration nothing references is dead. `.agents/rules.md` explains each rule and its
|
||||
allowlist.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Do not introduce a new visual system for one screen.
|
||||
- Do not manually edit generated localization or provider files.
|
||||
- Avoid broad layout rewrites unless the requested change requires them.
|
||||
- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors
|
||||
bypass them immediately.
|
||||
- Do not leave loading animations active when callbacks throw. Test the exception path, not only the successful tap.
|
||||
|
||||
## Current Interaction Examples
|
||||
|
||||
- `CoreStatusButton` watches `coreStatusProvider` but keeps its 600-millisecond connecting hold locally. Taps are ignored
|
||||
during the hold or genuine connecting state; disconnected cancels the hold immediately.
|
||||
- Proxy delay testing writes `0` while pending, the measured delay on success, and `-1` on failure. `DelayTestButton` resets
|
||||
its animation in `finally`.
|
||||
- Do not leave loading animations active when callbacks throw.
|
||||
- Do not manually edit generated localization or provider files.
|
||||
|
||||
Reference in New Issue
Block a user